@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
package/dist/cli.cjs
ADDED
|
@@ -0,0 +1,1853 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// src/cli/scan-command.ts
|
|
5
|
+
var import_node_path2 = require("path");
|
|
6
|
+
|
|
7
|
+
// src/scanner/route-scanner.ts
|
|
8
|
+
var import_promises = require("fs/promises");
|
|
9
|
+
var import_glob = require("glob");
|
|
10
|
+
|
|
11
|
+
// src/conventions/url-patterns.ts
|
|
12
|
+
function extractPathParams(url) {
|
|
13
|
+
const params = [];
|
|
14
|
+
for (const match of url.matchAll(/:(\w+)/g)) {
|
|
15
|
+
params.push(match[1]);
|
|
16
|
+
}
|
|
17
|
+
for (const match of url.matchAll(/\{(\w+)\}/g)) {
|
|
18
|
+
if (!params.includes(match[1])) {
|
|
19
|
+
params.push(match[1]);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return params;
|
|
23
|
+
}
|
|
24
|
+
function normalizeUrl(url) {
|
|
25
|
+
let normalized = url.split("?")[0].split("#")[0];
|
|
26
|
+
normalized = normalized.replace(/\{(\w+)\}/g, ":$1");
|
|
27
|
+
if (!normalized.startsWith("/")) {
|
|
28
|
+
normalized = "/" + normalized;
|
|
29
|
+
}
|
|
30
|
+
if (normalized.length > 1 && normalized.endsWith("/")) {
|
|
31
|
+
normalized = normalized.slice(0, -1);
|
|
32
|
+
}
|
|
33
|
+
return normalized;
|
|
34
|
+
}
|
|
35
|
+
function urlToHandlerName(url, method) {
|
|
36
|
+
const prefix = method.toLowerCase();
|
|
37
|
+
const segments = url.replace(/^\//, "").split("/").map((segment) => {
|
|
38
|
+
if (segment.startsWith(":")) {
|
|
39
|
+
const param = segment.slice(1);
|
|
40
|
+
return "By" + capitalize(param);
|
|
41
|
+
}
|
|
42
|
+
return segment.split(/[-_]/).map(capitalize).join("");
|
|
43
|
+
});
|
|
44
|
+
return prefix + segments.join("");
|
|
45
|
+
}
|
|
46
|
+
function extractQueryParams(url) {
|
|
47
|
+
const queryIndex = url.indexOf("?");
|
|
48
|
+
if (queryIndex === -1) return [];
|
|
49
|
+
const queryString = url.slice(queryIndex + 1).split("#")[0];
|
|
50
|
+
if (!queryString) return [];
|
|
51
|
+
const params = [];
|
|
52
|
+
const seen = /* @__PURE__ */ new Set();
|
|
53
|
+
for (const pair of queryString.split("&")) {
|
|
54
|
+
const eqIndex = pair.indexOf("=");
|
|
55
|
+
const rawName = eqIndex === -1 ? pair : pair.slice(0, eqIndex);
|
|
56
|
+
if (!rawName) continue;
|
|
57
|
+
let name;
|
|
58
|
+
try {
|
|
59
|
+
name = decodeURIComponent(rawName);
|
|
60
|
+
} catch {
|
|
61
|
+
name = rawName;
|
|
62
|
+
}
|
|
63
|
+
if (seen.has(name)) continue;
|
|
64
|
+
seen.add(name);
|
|
65
|
+
params.push({ name, type: "string", required: true });
|
|
66
|
+
}
|
|
67
|
+
return params;
|
|
68
|
+
}
|
|
69
|
+
function capitalize(s) {
|
|
70
|
+
if (!s) return "";
|
|
71
|
+
return s[0].toUpperCase() + s.slice(1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/conventions/convention-engine.ts
|
|
75
|
+
function inferConventions(url, context = {}) {
|
|
76
|
+
const notes = [];
|
|
77
|
+
let method;
|
|
78
|
+
if (context.explicitMethod) {
|
|
79
|
+
method = context.explicitMethod.toUpperCase();
|
|
80
|
+
} else if (context.elementTag === "form") {
|
|
81
|
+
method = "POST";
|
|
82
|
+
notes.push("Inferred POST from <form> context");
|
|
83
|
+
} else {
|
|
84
|
+
method = "GET";
|
|
85
|
+
}
|
|
86
|
+
let responseFormat;
|
|
87
|
+
if (context.fetchResponseType) {
|
|
88
|
+
const fmt = context.fetchResponseType.toLowerCase();
|
|
89
|
+
if (fmt === "json" || fmt === "html" || fmt === "text") {
|
|
90
|
+
responseFormat = fmt;
|
|
91
|
+
} else {
|
|
92
|
+
responseFormat = "json";
|
|
93
|
+
notes.push(`Unknown response type "${context.fetchResponseType}", defaulting to json`);
|
|
94
|
+
}
|
|
95
|
+
} else if (url.endsWith(".html") || url.includes("/pages/") || url.includes("/partials/")) {
|
|
96
|
+
responseFormat = "html";
|
|
97
|
+
notes.push("Inferred html response from URL pattern");
|
|
98
|
+
} else {
|
|
99
|
+
responseFormat = "json";
|
|
100
|
+
}
|
|
101
|
+
const handlerName = urlToHandlerName(url, method);
|
|
102
|
+
return { method, responseFormat, handlerName, notes };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// src/scanner/hyperscript-extractor.ts
|
|
106
|
+
var ATTR_PATTERNS = [
|
|
107
|
+
/_\s*=\s*"([^"]+)"/g,
|
|
108
|
+
/_\s*=\s*'([^']+)'/g,
|
|
109
|
+
/_\s*=\s*`([^`]+)`/g,
|
|
110
|
+
/_=\{`([^`]+)`\}/g,
|
|
111
|
+
// JSX template literal
|
|
112
|
+
/_=\{['"]([^'"]+)['"]\}/g
|
|
113
|
+
// JSX string
|
|
114
|
+
];
|
|
115
|
+
var SCRIPT_PATTERN = /<script[^>]*type=["']?text\/hyperscript["']?[^>]*>([\s\S]*?)<\/script>/gi;
|
|
116
|
+
var FETCH_COMMAND = /\bfetch\s+['"]?([^\s'"]+)['"]?(?:\s+as\s+(\w+))?(?:\s+via\s+(\w+))?/gi;
|
|
117
|
+
var SEND_FORM = /\bsend\s+(?:the\s+)?form\s+to\s+['"]?([^\s'"]+)['"]?(?:\s+as\s+(\w+))?/gi;
|
|
118
|
+
function extractHyperscriptRoutes(content, options) {
|
|
119
|
+
const routes = [];
|
|
120
|
+
const seen = /* @__PURE__ */ new Set();
|
|
121
|
+
const regions = extractHyperscriptRegions(content);
|
|
122
|
+
for (const region of regions) {
|
|
123
|
+
for (const match of region.code.matchAll(FETCH_COMMAND)) {
|
|
124
|
+
const rawUrl = match[1];
|
|
125
|
+
const responseType = match[2];
|
|
126
|
+
const method = match[3];
|
|
127
|
+
addRoute(routes, seen, rawUrl, {
|
|
128
|
+
file: options.file,
|
|
129
|
+
line: region.line,
|
|
130
|
+
kind: "fetch",
|
|
131
|
+
raw: match[0],
|
|
132
|
+
explicitMethod: method,
|
|
133
|
+
fetchResponseType: responseType
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
for (const match of region.code.matchAll(SEND_FORM)) {
|
|
137
|
+
const rawUrl = match[1];
|
|
138
|
+
const responseType = match[2];
|
|
139
|
+
addRoute(routes, seen, rawUrl, {
|
|
140
|
+
file: options.file,
|
|
141
|
+
line: region.line,
|
|
142
|
+
kind: "fetch",
|
|
143
|
+
raw: match[0],
|
|
144
|
+
explicitMethod: "POST",
|
|
145
|
+
fetchResponseType: responseType
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return routes;
|
|
150
|
+
}
|
|
151
|
+
function extractHyperscriptRegions(content) {
|
|
152
|
+
const regions = [];
|
|
153
|
+
const lines = content.split("\n");
|
|
154
|
+
for (const pattern of ATTR_PATTERNS) {
|
|
155
|
+
pattern.lastIndex = 0;
|
|
156
|
+
let match2;
|
|
157
|
+
while ((match2 = pattern.exec(content)) !== null) {
|
|
158
|
+
const line = lineNumberAt(content, match2.index);
|
|
159
|
+
regions.push({ code: match2[1], line });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
SCRIPT_PATTERN.lastIndex = 0;
|
|
163
|
+
let match;
|
|
164
|
+
while ((match = SCRIPT_PATTERN.exec(content)) !== null) {
|
|
165
|
+
const line = lineNumberAt(content, match.index);
|
|
166
|
+
regions.push({ code: match[1], line });
|
|
167
|
+
}
|
|
168
|
+
return regions;
|
|
169
|
+
}
|
|
170
|
+
function lineNumberAt(content, index) {
|
|
171
|
+
let line = 1;
|
|
172
|
+
for (let i = 0; i < index && i < content.length; i++) {
|
|
173
|
+
if (content[i] === "\n") line++;
|
|
174
|
+
}
|
|
175
|
+
return line;
|
|
176
|
+
}
|
|
177
|
+
function addRoute(routes, seen, rawUrl, options) {
|
|
178
|
+
if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://")) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (rawUrl.startsWith("{{") || rawUrl.startsWith("${")) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (!rawUrl.startsWith("/") && !rawUrl.includes("/")) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const queryParams = extractQueryParams(rawUrl);
|
|
188
|
+
const path = normalizeUrl(rawUrl);
|
|
189
|
+
const conventions = inferConventions(path, {
|
|
190
|
+
explicitMethod: options.explicitMethod,
|
|
191
|
+
fetchResponseType: options.fetchResponseType
|
|
192
|
+
});
|
|
193
|
+
const key = `${conventions.method}:${path}`;
|
|
194
|
+
if (seen.has(key)) return;
|
|
195
|
+
seen.add(key);
|
|
196
|
+
routes.push({
|
|
197
|
+
path,
|
|
198
|
+
method: conventions.method,
|
|
199
|
+
responseFormat: conventions.responseFormat,
|
|
200
|
+
source: {
|
|
201
|
+
file: options.file,
|
|
202
|
+
line: options.line,
|
|
203
|
+
kind: options.kind,
|
|
204
|
+
raw: options.raw
|
|
205
|
+
},
|
|
206
|
+
pathParams: extractPathParams(path),
|
|
207
|
+
...queryParams.length > 0 ? { queryParams } : {},
|
|
208
|
+
handlerName: conventions.handlerName,
|
|
209
|
+
notes: conventions.notes
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// src/scanner/htmx-extractor.ts
|
|
214
|
+
var HTMX_REQUEST_PATTERN = /\b(hx-get|hx-post|hx-put|hx-patch|hx-delete)\s*=\s*["']([^"']+)["']/gi;
|
|
215
|
+
var FIXI_ACTION_PATTERN = /\bfx-action\s*=\s*["']([^"']+)["']/gi;
|
|
216
|
+
var FIXI_METHOD_PATTERN = /\bfx-method\s*=\s*["'](GET|POST|PUT|PATCH|DELETE)["']/gi;
|
|
217
|
+
var HTMX_METHOD_MAP = {
|
|
218
|
+
"hx-get": "GET",
|
|
219
|
+
"hx-post": "POST",
|
|
220
|
+
"hx-put": "PUT",
|
|
221
|
+
"hx-patch": "PATCH",
|
|
222
|
+
"hx-delete": "DELETE"
|
|
223
|
+
};
|
|
224
|
+
function extractHtmxRoutes(content, options) {
|
|
225
|
+
const routes = [];
|
|
226
|
+
const seen = /* @__PURE__ */ new Set();
|
|
227
|
+
HTMX_REQUEST_PATTERN.lastIndex = 0;
|
|
228
|
+
let match;
|
|
229
|
+
while ((match = HTMX_REQUEST_PATTERN.exec(content)) !== null) {
|
|
230
|
+
const attrName = match[1].toLowerCase();
|
|
231
|
+
const rawUrl = match[2];
|
|
232
|
+
const method = HTMX_METHOD_MAP[attrName];
|
|
233
|
+
if (!method) continue;
|
|
234
|
+
addRoute2(routes, seen, rawUrl, {
|
|
235
|
+
file: options.file,
|
|
236
|
+
line: lineNumberAt2(content, match.index),
|
|
237
|
+
kind: "hx-attr",
|
|
238
|
+
raw: `${match[1]}="${match[2]}"`,
|
|
239
|
+
explicitMethod: method,
|
|
240
|
+
// htmx default response is html (server-rendered partials)
|
|
241
|
+
fetchResponseType: "html"
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
const fxMethods = collectFxMethods(content);
|
|
245
|
+
FIXI_ACTION_PATTERN.lastIndex = 0;
|
|
246
|
+
while ((match = FIXI_ACTION_PATTERN.exec(content)) !== null) {
|
|
247
|
+
const rawUrl = match[1];
|
|
248
|
+
const line = lineNumberAt2(content, match.index);
|
|
249
|
+
const nearbyMethod = findNearbyFxMethod(content, match.index, fxMethods);
|
|
250
|
+
addRoute2(routes, seen, rawUrl, {
|
|
251
|
+
file: options.file,
|
|
252
|
+
line,
|
|
253
|
+
kind: "fx-attr",
|
|
254
|
+
raw: `fx-action="${match[1]}"`,
|
|
255
|
+
explicitMethod: nearbyMethod
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return routes;
|
|
259
|
+
}
|
|
260
|
+
function lineNumberAt2(content, index) {
|
|
261
|
+
let line = 1;
|
|
262
|
+
for (let i = 0; i < index && i < content.length; i++) {
|
|
263
|
+
if (content[i] === "\n") line++;
|
|
264
|
+
}
|
|
265
|
+
return line;
|
|
266
|
+
}
|
|
267
|
+
function collectFxMethods(content) {
|
|
268
|
+
const methods = [];
|
|
269
|
+
FIXI_METHOD_PATTERN.lastIndex = 0;
|
|
270
|
+
let match;
|
|
271
|
+
while ((match = FIXI_METHOD_PATTERN.exec(content)) !== null) {
|
|
272
|
+
methods.push({ method: match[1], index: match.index });
|
|
273
|
+
}
|
|
274
|
+
return methods;
|
|
275
|
+
}
|
|
276
|
+
function findNearbyFxMethod(content, actionIndex, fxMethods) {
|
|
277
|
+
for (const fm of fxMethods) {
|
|
278
|
+
if (Math.abs(fm.index - actionIndex) < 300) {
|
|
279
|
+
return fm.method;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return void 0;
|
|
283
|
+
}
|
|
284
|
+
function addRoute2(routes, seen, rawUrl, options) {
|
|
285
|
+
if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://")) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const queryParams = extractQueryParams(rawUrl);
|
|
289
|
+
const path = normalizeUrl(rawUrl);
|
|
290
|
+
const conventions = inferConventions(path, {
|
|
291
|
+
explicitMethod: options.explicitMethod,
|
|
292
|
+
fetchResponseType: options.fetchResponseType
|
|
293
|
+
});
|
|
294
|
+
const key = `${conventions.method}:${path}`;
|
|
295
|
+
if (seen.has(key)) return;
|
|
296
|
+
seen.add(key);
|
|
297
|
+
routes.push({
|
|
298
|
+
path,
|
|
299
|
+
method: conventions.method,
|
|
300
|
+
responseFormat: conventions.responseFormat,
|
|
301
|
+
source: {
|
|
302
|
+
file: options.file,
|
|
303
|
+
line: options.line,
|
|
304
|
+
kind: options.kind,
|
|
305
|
+
raw: options.raw
|
|
306
|
+
},
|
|
307
|
+
pathParams: extractPathParams(path),
|
|
308
|
+
...queryParams.length > 0 ? { queryParams } : {},
|
|
309
|
+
handlerName: conventions.handlerName,
|
|
310
|
+
notes: conventions.notes
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// src/scanner/form-scanner.ts
|
|
315
|
+
var FORM_BLOCK = /<form\b[^>]*>([\s\S]*?)<\/form>/gi;
|
|
316
|
+
var NAMED_FIELD = /<(input|select|textarea)\b([^>]*)\bname\s*=\s*["']([^"']+)["']([^>]*)>/gi;
|
|
317
|
+
var TYPE_ATTR = /\btype\s*=\s*["']([^"']+)["']/i;
|
|
318
|
+
var REQUIRED_ATTR = /\brequired\b/i;
|
|
319
|
+
var TYPE_MAP = {
|
|
320
|
+
text: "string",
|
|
321
|
+
email: "string",
|
|
322
|
+
password: "string",
|
|
323
|
+
tel: "string",
|
|
324
|
+
url: "string",
|
|
325
|
+
search: "string",
|
|
326
|
+
hidden: "string",
|
|
327
|
+
color: "string",
|
|
328
|
+
date: "string",
|
|
329
|
+
"datetime-local": "string",
|
|
330
|
+
month: "string",
|
|
331
|
+
week: "string",
|
|
332
|
+
time: "string",
|
|
333
|
+
number: "number",
|
|
334
|
+
range: "number",
|
|
335
|
+
checkbox: "boolean",
|
|
336
|
+
file: "file"
|
|
337
|
+
};
|
|
338
|
+
function extractFormFields(formInnerHtml) {
|
|
339
|
+
const fields = [];
|
|
340
|
+
const seen = /* @__PURE__ */ new Set();
|
|
341
|
+
NAMED_FIELD.lastIndex = 0;
|
|
342
|
+
let match;
|
|
343
|
+
while ((match = NAMED_FIELD.exec(formInnerHtml)) !== null) {
|
|
344
|
+
const tag = match[1].toLowerCase();
|
|
345
|
+
const attrsBefore = match[2];
|
|
346
|
+
const name = match[3];
|
|
347
|
+
const attrsAfter = match[4];
|
|
348
|
+
const allAttrs = attrsBefore + " " + attrsAfter;
|
|
349
|
+
if (seen.has(name)) continue;
|
|
350
|
+
seen.add(name);
|
|
351
|
+
let type = "string";
|
|
352
|
+
if (tag === "select" || tag === "textarea") {
|
|
353
|
+
type = "string";
|
|
354
|
+
} else {
|
|
355
|
+
const typeMatch = allAttrs.match(TYPE_ATTR);
|
|
356
|
+
const inputType = typeMatch ? typeMatch[1].toLowerCase() : "text";
|
|
357
|
+
type = TYPE_MAP[inputType] ?? "unknown";
|
|
358
|
+
}
|
|
359
|
+
const required = REQUIRED_ATTR.test(allAttrs);
|
|
360
|
+
fields.push({ name, type, required });
|
|
361
|
+
}
|
|
362
|
+
return fields;
|
|
363
|
+
}
|
|
364
|
+
function extractFormBodies(content) {
|
|
365
|
+
const result = /* @__PURE__ */ new Map();
|
|
366
|
+
FORM_BLOCK.lastIndex = 0;
|
|
367
|
+
let match;
|
|
368
|
+
while ((match = FORM_BLOCK.exec(content)) !== null) {
|
|
369
|
+
const formTag = content.slice(
|
|
370
|
+
match.index,
|
|
371
|
+
match.index + content.slice(match.index).indexOf(">") + 1
|
|
372
|
+
);
|
|
373
|
+
const formBody = match[1];
|
|
374
|
+
const url = extractFormUrl(formTag);
|
|
375
|
+
if (!url) continue;
|
|
376
|
+
const fields = extractFormFields(formBody);
|
|
377
|
+
if (fields.length > 0) {
|
|
378
|
+
result.set(url, fields);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return result;
|
|
382
|
+
}
|
|
383
|
+
function extractFormUrl(formTag) {
|
|
384
|
+
const hxMatch = formTag.match(/\bhx-(?:post|put|patch)\s*=\s*["']([^"']+)["']/i);
|
|
385
|
+
if (hxMatch) return hxMatch[1];
|
|
386
|
+
const fxMatch = formTag.match(/\bfx-action\s*=\s*["']([^"']+)["']/i);
|
|
387
|
+
if (fxMatch) return fxMatch[1];
|
|
388
|
+
const hsMatch = formTag.match(/\bsend\s+(?:the\s+)?form\s+to\s+['"]?([^\s'"]+)['"]?/i);
|
|
389
|
+
if (hsMatch) return hsMatch[1];
|
|
390
|
+
return void 0;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// src/scanner/conflict-detector.ts
|
|
394
|
+
function detectConflicts(routes) {
|
|
395
|
+
const warnings = [];
|
|
396
|
+
const groups = /* @__PURE__ */ new Map();
|
|
397
|
+
for (const route of routes) {
|
|
398
|
+
const key = `${route.method}:${route.path}`;
|
|
399
|
+
const existing = groups.get(key) ?? [];
|
|
400
|
+
existing.push(route);
|
|
401
|
+
groups.set(key, existing);
|
|
402
|
+
}
|
|
403
|
+
for (const [key, groupRoutes] of groups) {
|
|
404
|
+
if (groupRoutes.length < 2) continue;
|
|
405
|
+
const conflicts = [];
|
|
406
|
+
const formats = [...new Set(groupRoutes.map((r) => r.responseFormat))];
|
|
407
|
+
if (formats.length > 1) {
|
|
408
|
+
conflicts.push({
|
|
409
|
+
field: "responseFormat",
|
|
410
|
+
values: formats,
|
|
411
|
+
message: `Conflicting response formats: ${formats.join(" vs ")}`
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
if (["POST", "PUT", "PATCH"].includes(groupRoutes[0].method)) {
|
|
415
|
+
const bodySignatures = groupRoutes.map(
|
|
416
|
+
(r) => JSON.stringify((r.requestBody ?? []).map((f) => f.name).sort())
|
|
417
|
+
);
|
|
418
|
+
const uniqueSignatures = [...new Set(bodySignatures)];
|
|
419
|
+
if (uniqueSignatures.length > 1) {
|
|
420
|
+
conflicts.push({
|
|
421
|
+
field: "requestBody",
|
|
422
|
+
values: uniqueSignatures,
|
|
423
|
+
message: "Request body fields differ across declarations"
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
const querySignatures = groupRoutes.map(
|
|
428
|
+
(r) => JSON.stringify((r.queryParams ?? []).map((p) => p.name).sort())
|
|
429
|
+
);
|
|
430
|
+
const uniqueQuerySigs = [...new Set(querySignatures)];
|
|
431
|
+
if (uniqueQuerySigs.length > 1) {
|
|
432
|
+
conflicts.push({
|
|
433
|
+
field: "queryParams",
|
|
434
|
+
values: uniqueQuerySigs,
|
|
435
|
+
message: "Query parameters differ across declarations"
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
if (conflicts.length > 0) {
|
|
439
|
+
warnings.push({
|
|
440
|
+
routeKey: key,
|
|
441
|
+
conflicts,
|
|
442
|
+
sources: groupRoutes.map((r) => r.source)
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return warnings;
|
|
447
|
+
}
|
|
448
|
+
function formatConflicts(warnings) {
|
|
449
|
+
const lines = [];
|
|
450
|
+
for (const warning of warnings) {
|
|
451
|
+
lines.push(` ${warning.routeKey}:`);
|
|
452
|
+
for (const conflict of warning.conflicts) {
|
|
453
|
+
lines.push(` - ${conflict.message}`);
|
|
454
|
+
}
|
|
455
|
+
lines.push(` Sources:`);
|
|
456
|
+
for (const source of warning.sources) {
|
|
457
|
+
lines.push(` ${source.file}:${source.line ?? "?"} (${source.kind})`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return lines.join("\n");
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// src/scanner/route-scanner.ts
|
|
464
|
+
var DEFAULT_INCLUDE = ["**/*.html", "**/*.htm"];
|
|
465
|
+
var DEFAULT_EXCLUDE = ["**/node_modules/**", "**/dist/**", "**/.git/**"];
|
|
466
|
+
function scanRoutes(content, file) {
|
|
467
|
+
const hsRoutes = extractHyperscriptRoutes(content, { file });
|
|
468
|
+
const htmxRoutes = extractHtmxRoutes(content, { file });
|
|
469
|
+
const all = [...hsRoutes, ...htmxRoutes];
|
|
470
|
+
const deduped = deduplicateRoutes(all);
|
|
471
|
+
const formBodies = extractFormBodies(content);
|
|
472
|
+
for (const route of deduped) {
|
|
473
|
+
if (["POST", "PUT", "PATCH"].includes(route.method)) {
|
|
474
|
+
const rawUrl = route.path;
|
|
475
|
+
const fields = formBodies.get(rawUrl);
|
|
476
|
+
if (fields && fields.length > 0) {
|
|
477
|
+
route.requestBody = fields;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return deduped;
|
|
482
|
+
}
|
|
483
|
+
async function scanDirectory(dir, options = {}) {
|
|
484
|
+
const include = options.include ?? DEFAULT_INCLUDE;
|
|
485
|
+
const exclude = options.exclude ?? DEFAULT_EXCLUDE;
|
|
486
|
+
const files = [];
|
|
487
|
+
for (const pattern of include) {
|
|
488
|
+
const matches = await (0, import_glob.glob)(pattern, {
|
|
489
|
+
cwd: dir,
|
|
490
|
+
absolute: true,
|
|
491
|
+
ignore: exclude
|
|
492
|
+
});
|
|
493
|
+
files.push(...matches);
|
|
494
|
+
}
|
|
495
|
+
const uniqueFiles = [...new Set(files)];
|
|
496
|
+
const allRoutes = [];
|
|
497
|
+
const errors = [];
|
|
498
|
+
for (const file of uniqueFiles) {
|
|
499
|
+
try {
|
|
500
|
+
const content = await (0, import_promises.readFile)(file, "utf-8");
|
|
501
|
+
const routes = scanRoutes(content, file);
|
|
502
|
+
allRoutes.push(...routes);
|
|
503
|
+
} catch (err) {
|
|
504
|
+
errors.push({
|
|
505
|
+
file,
|
|
506
|
+
message: err instanceof Error ? err.message : String(err)
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
const conflicts = detectConflicts(allRoutes);
|
|
511
|
+
const deduped = deduplicateRoutes(allRoutes);
|
|
512
|
+
const ignored = options.ignore ?? [];
|
|
513
|
+
const filtered = deduped.filter(
|
|
514
|
+
(route) => !ignored.some((pattern) => matchIgnorePattern(route.path, pattern))
|
|
515
|
+
);
|
|
516
|
+
return {
|
|
517
|
+
routes: filtered,
|
|
518
|
+
filesScanned: uniqueFiles,
|
|
519
|
+
errors,
|
|
520
|
+
conflicts
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
function deduplicateRoutes(routes) {
|
|
524
|
+
const seen = /* @__PURE__ */ new Map();
|
|
525
|
+
for (const route of routes) {
|
|
526
|
+
const key = `${route.method}:${route.path}`;
|
|
527
|
+
if (!seen.has(key)) {
|
|
528
|
+
seen.set(key, route);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return [...seen.values()];
|
|
532
|
+
}
|
|
533
|
+
function matchIgnorePattern(path, pattern) {
|
|
534
|
+
if (pattern.endsWith("/**")) {
|
|
535
|
+
const prefix = pattern.slice(0, -3);
|
|
536
|
+
return path.startsWith(prefix);
|
|
537
|
+
}
|
|
538
|
+
return path === pattern;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// src/cli/config.ts
|
|
542
|
+
var import_promises2 = require("fs/promises");
|
|
543
|
+
var import_node_path = require("path");
|
|
544
|
+
var CONFIG_FILENAMES = [".serverbridgerc.json", ".serverbridgerc", "serverbridge.config.json"];
|
|
545
|
+
async function loadConfig(cwd, overrides = {}) {
|
|
546
|
+
let fileConfig = {};
|
|
547
|
+
for (const filename of CONFIG_FILENAMES) {
|
|
548
|
+
try {
|
|
549
|
+
const content = await (0, import_promises2.readFile)((0, import_node_path.join)(cwd, filename), "utf-8");
|
|
550
|
+
fileConfig = JSON.parse(content);
|
|
551
|
+
break;
|
|
552
|
+
} catch {
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return {
|
|
556
|
+
framework: overrides.framework ?? fileConfig.framework ?? "express",
|
|
557
|
+
output: overrides.output ?? fileConfig.output ?? "./server/routes",
|
|
558
|
+
typescript: overrides.typescript ?? fileConfig.typescript ?? true,
|
|
559
|
+
include: overrides.include ?? fileConfig.include ?? ["**/*.{html,htm}"],
|
|
560
|
+
exclude: overrides.exclude ?? fileConfig.exclude ?? ["node_modules/**", "dist/**"],
|
|
561
|
+
basePath: overrides.basePath ?? fileConfig.basePath,
|
|
562
|
+
ignore: overrides.ignore ?? fileConfig.ignore
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// src/cli/scan-command.ts
|
|
567
|
+
async function runScan(options) {
|
|
568
|
+
const cwd = (0, import_node_path2.resolve)(options.dir);
|
|
569
|
+
const config = await loadConfig(cwd, {
|
|
570
|
+
include: options.include,
|
|
571
|
+
exclude: options.exclude,
|
|
572
|
+
ignore: options.ignore
|
|
573
|
+
});
|
|
574
|
+
const result = await scanDirectory(cwd, {
|
|
575
|
+
include: config.include,
|
|
576
|
+
exclude: config.exclude,
|
|
577
|
+
ignore: config.ignore
|
|
578
|
+
});
|
|
579
|
+
if (result.errors.length > 0) {
|
|
580
|
+
for (const err of result.errors) {
|
|
581
|
+
console.error(`Error in ${err.file}: ${err.message}`);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
if (result.conflicts.length > 0) {
|
|
585
|
+
console.error(
|
|
586
|
+
`
|
|
587
|
+
Warning: ${result.conflicts.length} route conflict(s) detected:
|
|
588
|
+
${formatConflicts(result.conflicts)}
|
|
589
|
+
`
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
if (options.format === "table") {
|
|
593
|
+
printTable(result.routes);
|
|
594
|
+
} else {
|
|
595
|
+
console.log(JSON.stringify(result, null, 2));
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
function printTable(routes) {
|
|
599
|
+
if (routes.length === 0) {
|
|
600
|
+
console.log("No routes found.");
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
console.log(`Found ${routes.length} route(s):
|
|
604
|
+
`);
|
|
605
|
+
console.log(" METHOD PATH FORMAT SOURCE");
|
|
606
|
+
console.log(" ------ ---------------------------- ------ ------");
|
|
607
|
+
for (const route of routes) {
|
|
608
|
+
const method = route.method.padEnd(6);
|
|
609
|
+
const path = route.path.padEnd(28);
|
|
610
|
+
const format = route.responseFormat.padEnd(6);
|
|
611
|
+
const source = route.source.kind;
|
|
612
|
+
console.log(` ${method} ${path} ${format} ${source}`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// src/cli/generate-command.ts
|
|
617
|
+
var import_node_path4 = require("path");
|
|
618
|
+
var import_promises4 = require("fs/promises");
|
|
619
|
+
|
|
620
|
+
// src/generators/template-helpers.ts
|
|
621
|
+
var ROUTE_MARKER = "// @serverbridge-route:";
|
|
622
|
+
var USER_START = "// @serverbridge-user-start";
|
|
623
|
+
var USER_END = "// @serverbridge-user-end";
|
|
624
|
+
function routeMarker(route) {
|
|
625
|
+
return `${ROUTE_MARKER} ${route.method} ${route.path}`;
|
|
626
|
+
}
|
|
627
|
+
function todoBlock(route, indent = " ") {
|
|
628
|
+
const lines = [];
|
|
629
|
+
lines.push(`${indent}// TODO: Implement ${route.method} ${route.path}`);
|
|
630
|
+
if (route.pathParams.length > 0) {
|
|
631
|
+
lines.push(`${indent}// Path params: ${route.pathParams.join(", ")}`);
|
|
632
|
+
}
|
|
633
|
+
if (route.queryParams && route.queryParams.length > 0) {
|
|
634
|
+
lines.push(`${indent}// Query params: ${route.queryParams.map((p) => p.name).join(", ")}`);
|
|
635
|
+
}
|
|
636
|
+
if (route.requestBody && route.requestBody.length > 0) {
|
|
637
|
+
const fields = route.requestBody.map((f) => `${f.name}: ${f.type}${f.required ? "" : "?"}`).join(", ");
|
|
638
|
+
lines.push(`${indent}// Expected body: { ${fields} }`);
|
|
639
|
+
}
|
|
640
|
+
if (route.notes.length > 0) {
|
|
641
|
+
for (const note of route.notes) {
|
|
642
|
+
lines.push(`${indent}// Note: ${note}`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return lines.join("\n");
|
|
646
|
+
}
|
|
647
|
+
function groupRoutesByPrefix(routes) {
|
|
648
|
+
const groups = /* @__PURE__ */ new Map();
|
|
649
|
+
for (const route of routes) {
|
|
650
|
+
const segments = route.path.replace(/^\//, "").split("/");
|
|
651
|
+
const groupKey = segments.slice(0, Math.min(2, segments.length)).filter((s) => !s.startsWith(":")).join("-") || "root";
|
|
652
|
+
const group = groups.get(groupKey) ?? [];
|
|
653
|
+
group.push(route);
|
|
654
|
+
groups.set(groupKey, group);
|
|
655
|
+
}
|
|
656
|
+
return groups;
|
|
657
|
+
}
|
|
658
|
+
function groupToFilename(groupKey, typescript) {
|
|
659
|
+
return `${groupKey}.${typescript ? "ts" : "js"}`;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// src/generators/express-generator.ts
|
|
663
|
+
var ExpressGenerator = class {
|
|
664
|
+
framework = "express";
|
|
665
|
+
generate(routes, options) {
|
|
666
|
+
const ts = options.typescript !== false;
|
|
667
|
+
const files = [];
|
|
668
|
+
const warnings = [];
|
|
669
|
+
if (routes.length === 0) {
|
|
670
|
+
warnings.push("No routes to generate");
|
|
671
|
+
return { files, preserved: [], warnings };
|
|
672
|
+
}
|
|
673
|
+
const groups = groupRoutesByPrefix(routes);
|
|
674
|
+
for (const [groupKey, groupRoutes] of groups) {
|
|
675
|
+
const filename = groupToFilename(groupKey, ts);
|
|
676
|
+
const content = this.generateRouterFile(groupRoutes, groupKey, ts);
|
|
677
|
+
files.push({ path: `routes/${filename}`, content, isNew: true });
|
|
678
|
+
}
|
|
679
|
+
const indexContent = this.generateIndex(groups, ts);
|
|
680
|
+
files.push({ path: `routes/index.${ts ? "ts" : "js"}`, content: indexContent, isNew: true });
|
|
681
|
+
return { files, preserved: [], warnings };
|
|
682
|
+
}
|
|
683
|
+
generateRouterFile(routes, groupKey, ts) {
|
|
684
|
+
const lines = [];
|
|
685
|
+
lines.push(`// Generated by @hyperfixi/server-bridge \u2014 DO NOT EDIT scaffold markers`);
|
|
686
|
+
lines.push(`import { Router${ts ? ", Request, Response" : ""} } from 'express';`);
|
|
687
|
+
lines.push("");
|
|
688
|
+
lines.push("const router = Router();");
|
|
689
|
+
lines.push("");
|
|
690
|
+
for (const route of routes) {
|
|
691
|
+
lines.push(routeMarker(route));
|
|
692
|
+
lines.push(this.generateHandler(route, ts));
|
|
693
|
+
lines.push("");
|
|
694
|
+
}
|
|
695
|
+
lines.push("export default router;");
|
|
696
|
+
lines.push("");
|
|
697
|
+
return lines.join("\n");
|
|
698
|
+
}
|
|
699
|
+
generateHandler(route, ts) {
|
|
700
|
+
const method = route.method.toLowerCase();
|
|
701
|
+
const params = ts ? "(req: Request, res: Response)" : "(req, res)";
|
|
702
|
+
const lines = [];
|
|
703
|
+
lines.push(`router.${method}('${route.path}', async ${params} => {`);
|
|
704
|
+
lines.push(` ${USER_START}`);
|
|
705
|
+
lines.push(todoBlock(route, " "));
|
|
706
|
+
if (route.pathParams.length > 0) {
|
|
707
|
+
const paramList = route.pathParams.join(", ");
|
|
708
|
+
lines.push(` const { ${paramList} } = req.params;`);
|
|
709
|
+
}
|
|
710
|
+
if (route.queryParams && route.queryParams.length > 0) {
|
|
711
|
+
const paramList = route.queryParams.map((p) => p.name).join(", ");
|
|
712
|
+
lines.push(` const { ${paramList} } = req.query;`);
|
|
713
|
+
}
|
|
714
|
+
if (route.requestBody && route.requestBody.length > 0 && ["POST", "PUT", "PATCH"].includes(route.method)) {
|
|
715
|
+
const fieldList = route.requestBody.map((f) => f.name).join(", ");
|
|
716
|
+
lines.push(` const { ${fieldList} } = req.body;`);
|
|
717
|
+
}
|
|
718
|
+
if (route.responseFormat === "json") {
|
|
719
|
+
lines.push(` res.json({ message: 'Not implemented' });`);
|
|
720
|
+
} else if (route.responseFormat === "html") {
|
|
721
|
+
lines.push(` res.send('<div>Not implemented</div>');`);
|
|
722
|
+
} else {
|
|
723
|
+
lines.push(` res.send('Not implemented');`);
|
|
724
|
+
}
|
|
725
|
+
lines.push(` ${USER_END}`);
|
|
726
|
+
lines.push(`});`);
|
|
727
|
+
return lines.join("\n");
|
|
728
|
+
}
|
|
729
|
+
generateIndex(groups, ts) {
|
|
730
|
+
const lines = [];
|
|
731
|
+
lines.push(`// Generated by @hyperfixi/server-bridge \u2014 DO NOT EDIT`);
|
|
732
|
+
lines.push(`import { Router } from 'express';`);
|
|
733
|
+
for (const groupKey of groups.keys()) {
|
|
734
|
+
const filename = groupKey;
|
|
735
|
+
lines.push(`import ${toCamelCase(groupKey)}Router from './${filename}.js';`);
|
|
736
|
+
}
|
|
737
|
+
lines.push("");
|
|
738
|
+
lines.push("const router = Router();");
|
|
739
|
+
lines.push("");
|
|
740
|
+
for (const groupKey of groups.keys()) {
|
|
741
|
+
lines.push(`router.use(${toCamelCase(groupKey)}Router);`);
|
|
742
|
+
}
|
|
743
|
+
lines.push("");
|
|
744
|
+
lines.push("export default router;");
|
|
745
|
+
lines.push("");
|
|
746
|
+
return lines.join("\n");
|
|
747
|
+
}
|
|
748
|
+
};
|
|
749
|
+
function toCamelCase(str) {
|
|
750
|
+
return str.replace(/-(\w)/g, (_, c) => c.toUpperCase());
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// src/generators/hono-generator.ts
|
|
754
|
+
var HonoGenerator = class {
|
|
755
|
+
framework = "hono";
|
|
756
|
+
generate(routes, options) {
|
|
757
|
+
const ts = options.typescript !== false;
|
|
758
|
+
const files = [];
|
|
759
|
+
const warnings = [];
|
|
760
|
+
if (routes.length === 0) {
|
|
761
|
+
warnings.push("No routes to generate");
|
|
762
|
+
return { files, preserved: [], warnings };
|
|
763
|
+
}
|
|
764
|
+
const groups = groupRoutesByPrefix(routes);
|
|
765
|
+
for (const [groupKey, groupRoutes] of groups) {
|
|
766
|
+
const filename = groupToFilename(groupKey, ts);
|
|
767
|
+
const content = this.generateRouteFile(groupRoutes, groupKey, ts);
|
|
768
|
+
files.push({ path: `routes/${filename}`, content, isNew: true });
|
|
769
|
+
}
|
|
770
|
+
const indexContent = this.generateIndex(groups, ts);
|
|
771
|
+
files.push({ path: `routes/index.${ts ? "ts" : "js"}`, content: indexContent, isNew: true });
|
|
772
|
+
return { files, preserved: [], warnings };
|
|
773
|
+
}
|
|
774
|
+
generateRouteFile(routes, groupKey, ts) {
|
|
775
|
+
const lines = [];
|
|
776
|
+
lines.push(`// Generated by @hyperfixi/server-bridge \u2014 DO NOT EDIT scaffold markers`);
|
|
777
|
+
lines.push(`import { Hono } from 'hono';`);
|
|
778
|
+
lines.push("");
|
|
779
|
+
lines.push("const app = new Hono();");
|
|
780
|
+
lines.push("");
|
|
781
|
+
for (const route of routes) {
|
|
782
|
+
lines.push(routeMarker(route));
|
|
783
|
+
lines.push(this.generateHandler(route, ts));
|
|
784
|
+
lines.push("");
|
|
785
|
+
}
|
|
786
|
+
lines.push("export default app;");
|
|
787
|
+
lines.push("");
|
|
788
|
+
return lines.join("\n");
|
|
789
|
+
}
|
|
790
|
+
generateHandler(route, ts) {
|
|
791
|
+
const method = route.method.toLowerCase();
|
|
792
|
+
const lines = [];
|
|
793
|
+
lines.push(`app.${method}('${route.path}', async (c) => {`);
|
|
794
|
+
lines.push(` ${USER_START}`);
|
|
795
|
+
lines.push(todoBlock(route, " "));
|
|
796
|
+
if (route.pathParams.length > 0) {
|
|
797
|
+
for (const param of route.pathParams) {
|
|
798
|
+
lines.push(` const ${param} = c.req.param('${param}');`);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
if (route.queryParams && route.queryParams.length > 0) {
|
|
802
|
+
for (const param of route.queryParams) {
|
|
803
|
+
lines.push(` const ${param.name} = c.req.query('${param.name}');`);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
if (route.requestBody && route.requestBody.length > 0 && ["POST", "PUT", "PATCH"].includes(route.method)) {
|
|
807
|
+
lines.push(` const body = await c.req.json();`);
|
|
808
|
+
const fieldList = route.requestBody.map((f) => f.name).join(", ");
|
|
809
|
+
lines.push(` const { ${fieldList} } = body;`);
|
|
810
|
+
}
|
|
811
|
+
if (route.responseFormat === "json") {
|
|
812
|
+
lines.push(` return c.json({ message: 'Not implemented' });`);
|
|
813
|
+
} else if (route.responseFormat === "html") {
|
|
814
|
+
lines.push(` return c.html('<div>Not implemented</div>');`);
|
|
815
|
+
} else {
|
|
816
|
+
lines.push(` return c.text('Not implemented');`);
|
|
817
|
+
}
|
|
818
|
+
lines.push(` ${USER_END}`);
|
|
819
|
+
lines.push(`});`);
|
|
820
|
+
return lines.join("\n");
|
|
821
|
+
}
|
|
822
|
+
generateIndex(groups, ts) {
|
|
823
|
+
const lines = [];
|
|
824
|
+
lines.push(`// Generated by @hyperfixi/server-bridge \u2014 DO NOT EDIT`);
|
|
825
|
+
lines.push(`import { Hono } from 'hono';`);
|
|
826
|
+
for (const groupKey of groups.keys()) {
|
|
827
|
+
const filename = groupKey;
|
|
828
|
+
lines.push(`import ${toCamelCase2(groupKey)}Routes from './${filename}.js';`);
|
|
829
|
+
}
|
|
830
|
+
lines.push("");
|
|
831
|
+
lines.push("const app = new Hono();");
|
|
832
|
+
lines.push("");
|
|
833
|
+
for (const groupKey of groups.keys()) {
|
|
834
|
+
lines.push(`app.route('/', ${toCamelCase2(groupKey)}Routes);`);
|
|
835
|
+
}
|
|
836
|
+
lines.push("");
|
|
837
|
+
lines.push("export default app;");
|
|
838
|
+
lines.push("");
|
|
839
|
+
return lines.join("\n");
|
|
840
|
+
}
|
|
841
|
+
};
|
|
842
|
+
function toCamelCase2(str) {
|
|
843
|
+
return str.replace(/-(\w)/g, (_, c) => c.toUpperCase());
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// src/generators/openapi-generator.ts
|
|
847
|
+
var OpenAPIGenerator = class {
|
|
848
|
+
framework = "openapi";
|
|
849
|
+
generate(routes, options) {
|
|
850
|
+
const files = [];
|
|
851
|
+
const warnings = [];
|
|
852
|
+
if (routes.length === 0) {
|
|
853
|
+
warnings.push("No routes to generate");
|
|
854
|
+
return { files, preserved: [], warnings };
|
|
855
|
+
}
|
|
856
|
+
const spec = this.buildSpec(routes);
|
|
857
|
+
const yaml = this.toYaml(spec);
|
|
858
|
+
files.push({
|
|
859
|
+
path: "openapi.yaml",
|
|
860
|
+
content: yaml,
|
|
861
|
+
isNew: true
|
|
862
|
+
});
|
|
863
|
+
return { files, preserved: [], warnings };
|
|
864
|
+
}
|
|
865
|
+
buildSpec(routes) {
|
|
866
|
+
const paths = {};
|
|
867
|
+
for (const route of routes) {
|
|
868
|
+
const oaPath = route.path.replace(/:(\w+)/g, "{$1}");
|
|
869
|
+
const method = route.method.toLowerCase();
|
|
870
|
+
if (!paths[oaPath]) {
|
|
871
|
+
paths[oaPath] = {};
|
|
872
|
+
}
|
|
873
|
+
paths[oaPath][method] = this.buildOperation(route);
|
|
874
|
+
}
|
|
875
|
+
return {
|
|
876
|
+
openapi: "3.1.0",
|
|
877
|
+
info: {
|
|
878
|
+
title: "ServerBridge Generated API",
|
|
879
|
+
version: "0.1.0",
|
|
880
|
+
description: "Auto-extracted from hyperscript/htmx attributes"
|
|
881
|
+
},
|
|
882
|
+
paths
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
buildOperation(route) {
|
|
886
|
+
const op = {
|
|
887
|
+
operationId: route.handlerName,
|
|
888
|
+
summary: `Source: ${route.source.file}${route.source.line ? ":" + route.source.line : ""} (${route.source.kind})`,
|
|
889
|
+
responses: {
|
|
890
|
+
"200": this.buildResponse(route)
|
|
891
|
+
}
|
|
892
|
+
};
|
|
893
|
+
if (route.pathParams.length > 0) {
|
|
894
|
+
op.parameters = route.pathParams.map((name) => ({
|
|
895
|
+
name,
|
|
896
|
+
in: "path",
|
|
897
|
+
required: true,
|
|
898
|
+
schema: { type: inferParamType(name) }
|
|
899
|
+
}));
|
|
900
|
+
}
|
|
901
|
+
if (route.queryParams && route.queryParams.length > 0) {
|
|
902
|
+
const queryParamSpecs = route.queryParams.map((param) => ({
|
|
903
|
+
name: param.name,
|
|
904
|
+
in: "query",
|
|
905
|
+
required: param.required,
|
|
906
|
+
schema: { type: "string" }
|
|
907
|
+
}));
|
|
908
|
+
if (op.parameters) {
|
|
909
|
+
op.parameters.push(...queryParamSpecs);
|
|
910
|
+
} else {
|
|
911
|
+
op.parameters = queryParamSpecs;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
if (route.requestBody && route.requestBody.length > 0 && ["POST", "PUT", "PATCH"].includes(route.method)) {
|
|
915
|
+
const required = route.requestBody.filter((f) => f.required).map((f) => f.name);
|
|
916
|
+
const properties = {};
|
|
917
|
+
for (const field of route.requestBody) {
|
|
918
|
+
properties[field.name] = fieldToSchema(field.type);
|
|
919
|
+
}
|
|
920
|
+
op.requestBody = {
|
|
921
|
+
required: required.length > 0,
|
|
922
|
+
content: {
|
|
923
|
+
"application/json": {
|
|
924
|
+
schema: {
|
|
925
|
+
type: "object",
|
|
926
|
+
...required.length > 0 ? { required } : {},
|
|
927
|
+
properties
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
return op;
|
|
934
|
+
}
|
|
935
|
+
buildResponse(route) {
|
|
936
|
+
const contentType = route.responseFormat === "json" ? "application/json" : route.responseFormat === "html" ? "text/html" : "text/plain";
|
|
937
|
+
return {
|
|
938
|
+
description: "Success",
|
|
939
|
+
content: {
|
|
940
|
+
[contentType]: {
|
|
941
|
+
schema: route.responseFormat === "json" ? { type: "object" } : { type: "string" }
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* Minimal YAML serializer — avoids adding a yaml dependency.
|
|
948
|
+
* Handles the specific nested structure of OpenAPI specs.
|
|
949
|
+
*/
|
|
950
|
+
toYaml(obj, indent = 0) {
|
|
951
|
+
const pad = " ".repeat(indent);
|
|
952
|
+
if (obj === null || obj === void 0) {
|
|
953
|
+
return "null";
|
|
954
|
+
}
|
|
955
|
+
if (typeof obj === "string") {
|
|
956
|
+
if (obj === "" || obj.includes(":") || obj.includes("#") || obj.includes("'") || /^\d/.test(obj) || obj === "true" || obj === "false") {
|
|
957
|
+
return `'${obj.replace(/'/g, "''")}'`;
|
|
958
|
+
}
|
|
959
|
+
return obj;
|
|
960
|
+
}
|
|
961
|
+
if (typeof obj === "number" || typeof obj === "boolean") {
|
|
962
|
+
return String(obj);
|
|
963
|
+
}
|
|
964
|
+
if (Array.isArray(obj)) {
|
|
965
|
+
if (obj.length === 0) return "[]";
|
|
966
|
+
if (obj.every((item) => typeof item === "string" || typeof item === "number")) {
|
|
967
|
+
return `[${obj.map((item) => typeof item === "string" ? `'${item}'` : String(item)).join(", ")}]`;
|
|
968
|
+
}
|
|
969
|
+
return obj.map((item) => {
|
|
970
|
+
if (typeof item === "object" && item !== null) {
|
|
971
|
+
const inner = this.toYaml(item, indent + 1).trimStart();
|
|
972
|
+
return `${pad}- ${inner}`;
|
|
973
|
+
}
|
|
974
|
+
return `${pad}- ${this.toYaml(item)}`;
|
|
975
|
+
}).join("\n");
|
|
976
|
+
}
|
|
977
|
+
if (typeof obj === "object") {
|
|
978
|
+
const entries = Object.entries(obj);
|
|
979
|
+
if (entries.length === 0) return "{}";
|
|
980
|
+
return entries.map(([key, value]) => {
|
|
981
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
982
|
+
const inner = this.toYaml(value, indent + 1);
|
|
983
|
+
return `${pad}${key}:
|
|
984
|
+
${inner}`;
|
|
985
|
+
}
|
|
986
|
+
if (Array.isArray(value) && value.length > 0 && value.some((v) => typeof v === "object")) {
|
|
987
|
+
const inner = this.toYaml(value, indent + 1);
|
|
988
|
+
return `${pad}${key}:
|
|
989
|
+
${inner}`;
|
|
990
|
+
}
|
|
991
|
+
return `${pad}${key}: ${this.toYaml(value, indent + 1)}`;
|
|
992
|
+
}).join("\n");
|
|
993
|
+
}
|
|
994
|
+
return String(obj);
|
|
995
|
+
}
|
|
996
|
+
};
|
|
997
|
+
function inferParamType(name) {
|
|
998
|
+
if (name === "id" || name.endsWith("Id")) return "string";
|
|
999
|
+
if (name === "page" || name === "limit" || name === "offset") return "integer";
|
|
1000
|
+
return "string";
|
|
1001
|
+
}
|
|
1002
|
+
function fieldToSchema(type) {
|
|
1003
|
+
switch (type) {
|
|
1004
|
+
case "number":
|
|
1005
|
+
return { type: "number" };
|
|
1006
|
+
case "boolean":
|
|
1007
|
+
return { type: "boolean" };
|
|
1008
|
+
case "file":
|
|
1009
|
+
return { type: "string", format: "binary" };
|
|
1010
|
+
default:
|
|
1011
|
+
return { type: "string" };
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// src/generators/django-generator.ts
|
|
1016
|
+
var PY_ROUTE_MARKER = "# @serverbridge-route:";
|
|
1017
|
+
var PY_USER_START = "# @serverbridge-user-start";
|
|
1018
|
+
var PY_USER_END = "# @serverbridge-user-end";
|
|
1019
|
+
var DjangoGenerator = class {
|
|
1020
|
+
framework = "django";
|
|
1021
|
+
generate(routes, options) {
|
|
1022
|
+
const files = [];
|
|
1023
|
+
const warnings = [];
|
|
1024
|
+
if (routes.length === 0) {
|
|
1025
|
+
warnings.push("No routes to generate");
|
|
1026
|
+
return { files, preserved: [], warnings };
|
|
1027
|
+
}
|
|
1028
|
+
const groups = groupRoutesByPrefix(routes);
|
|
1029
|
+
for (const [groupKey, groupRoutes] of groups) {
|
|
1030
|
+
const views = this.generateViews(groupRoutes, groupKey);
|
|
1031
|
+
files.push({ path: `${groupKey}/views.py`, content: views, isNew: true });
|
|
1032
|
+
const urls = this.generateUrls(groupRoutes, groupKey);
|
|
1033
|
+
files.push({ path: `${groupKey}/urls.py`, content: urls, isNew: true });
|
|
1034
|
+
files.push({ path: `${groupKey}/__init__.py`, content: "", isNew: true });
|
|
1035
|
+
}
|
|
1036
|
+
const rootUrls = this.generateRootUrls(groups);
|
|
1037
|
+
files.push({ path: "urls.py", content: rootUrls, isNew: true });
|
|
1038
|
+
return { files, preserved: [], warnings };
|
|
1039
|
+
}
|
|
1040
|
+
generateViews(routes, groupKey) {
|
|
1041
|
+
const lines = [];
|
|
1042
|
+
lines.push(`# Generated by @hyperfixi/server-bridge \u2014 DO NOT EDIT scaffold markers`);
|
|
1043
|
+
lines.push(`from django.http import JsonResponse, HttpResponse`);
|
|
1044
|
+
const needsJson = routes.some(
|
|
1045
|
+
(r) => r.requestBody && r.requestBody.length > 0 && ["POST", "PUT", "PATCH"].includes(r.method)
|
|
1046
|
+
);
|
|
1047
|
+
if (needsJson) {
|
|
1048
|
+
lines.push(`import json`);
|
|
1049
|
+
}
|
|
1050
|
+
const hasNonGet = routes.some((r) => r.method !== "GET");
|
|
1051
|
+
if (hasNonGet) {
|
|
1052
|
+
lines.push(`from django.views.decorators.http import require_http_methods`);
|
|
1053
|
+
}
|
|
1054
|
+
lines.push("");
|
|
1055
|
+
for (const route of routes) {
|
|
1056
|
+
lines.push("");
|
|
1057
|
+
lines.push(`${PY_ROUTE_MARKER} ${route.method} ${route.path}`);
|
|
1058
|
+
lines.push(this.generateView(route));
|
|
1059
|
+
}
|
|
1060
|
+
lines.push("");
|
|
1061
|
+
return lines.join("\n");
|
|
1062
|
+
}
|
|
1063
|
+
generateView(route) {
|
|
1064
|
+
const lines = [];
|
|
1065
|
+
const funcName = toSnakeCase(route.handlerName);
|
|
1066
|
+
if (route.method !== "GET") {
|
|
1067
|
+
lines.push(`@require_http_methods(["${route.method}"])`);
|
|
1068
|
+
}
|
|
1069
|
+
const params = ["request", ...route.pathParams];
|
|
1070
|
+
lines.push(`def ${funcName}(${params.join(", ")}):`);
|
|
1071
|
+
lines.push(` ${PY_USER_START}`);
|
|
1072
|
+
lines.push(` # TODO: Implement ${route.method} ${route.path}`);
|
|
1073
|
+
if (route.pathParams.length > 0) {
|
|
1074
|
+
lines.push(` # Path params: ${route.pathParams.join(", ")}`);
|
|
1075
|
+
}
|
|
1076
|
+
if (route.requestBody && route.requestBody.length > 0) {
|
|
1077
|
+
const fields = route.requestBody.map((f) => `${f.name}: ${pythonType(f.type)}${f.required ? "" : " (optional)"}`).join(", ");
|
|
1078
|
+
lines.push(` # Expected body: { ${fields} }`);
|
|
1079
|
+
}
|
|
1080
|
+
for (const note of route.notes) {
|
|
1081
|
+
lines.push(` # Note: ${note}`);
|
|
1082
|
+
}
|
|
1083
|
+
if (route.queryParams && route.queryParams.length > 0) {
|
|
1084
|
+
for (const param of route.queryParams) {
|
|
1085
|
+
lines.push(` ${param.name} = request.GET.get('${param.name}')`);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
if (route.requestBody && route.requestBody.length > 0 && ["POST", "PUT", "PATCH"].includes(route.method)) {
|
|
1089
|
+
lines.push(` data = json.loads(request.body)`);
|
|
1090
|
+
for (const field of route.requestBody) {
|
|
1091
|
+
const default_ = field.required ? "" : ", None";
|
|
1092
|
+
lines.push(` ${field.name} = data.get('${field.name}'${default_})`);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
if (route.responseFormat === "json") {
|
|
1096
|
+
lines.push(` return JsonResponse({'message': 'Not implemented'})`);
|
|
1097
|
+
} else if (route.responseFormat === "html") {
|
|
1098
|
+
lines.push(` return HttpResponse('<div>Not implemented</div>')`);
|
|
1099
|
+
} else {
|
|
1100
|
+
lines.push(` return HttpResponse('Not implemented', content_type='text/plain')`);
|
|
1101
|
+
}
|
|
1102
|
+
lines.push(` ${PY_USER_END}`);
|
|
1103
|
+
return lines.join("\n");
|
|
1104
|
+
}
|
|
1105
|
+
generateUrls(routes, groupKey) {
|
|
1106
|
+
const lines = [];
|
|
1107
|
+
lines.push(`# Generated by @hyperfixi/server-bridge \u2014 DO NOT EDIT`);
|
|
1108
|
+
lines.push(`from django.urls import path`);
|
|
1109
|
+
lines.push(`from . import views`);
|
|
1110
|
+
lines.push("");
|
|
1111
|
+
lines.push("urlpatterns = [");
|
|
1112
|
+
for (const route of routes) {
|
|
1113
|
+
const djangoPath = toDjangoPath(route.path);
|
|
1114
|
+
const funcName = toSnakeCase(route.handlerName);
|
|
1115
|
+
lines.push(` path('${djangoPath}', views.${funcName}, name='${funcName}'),`);
|
|
1116
|
+
}
|
|
1117
|
+
lines.push("]");
|
|
1118
|
+
lines.push("");
|
|
1119
|
+
return lines.join("\n");
|
|
1120
|
+
}
|
|
1121
|
+
generateRootUrls(groups) {
|
|
1122
|
+
const lines = [];
|
|
1123
|
+
lines.push(`# Generated by @hyperfixi/server-bridge \u2014 DO NOT EDIT`);
|
|
1124
|
+
lines.push(`from django.urls import path, include`);
|
|
1125
|
+
lines.push("");
|
|
1126
|
+
lines.push("urlpatterns = [");
|
|
1127
|
+
for (const groupKey of groups.keys()) {
|
|
1128
|
+
lines.push(` path('', include('${groupKey}.urls')),`);
|
|
1129
|
+
}
|
|
1130
|
+
lines.push("]");
|
|
1131
|
+
lines.push("");
|
|
1132
|
+
return lines.join("\n");
|
|
1133
|
+
}
|
|
1134
|
+
};
|
|
1135
|
+
function toSnakeCase(str) {
|
|
1136
|
+
return str.replace(/([A-Z])/g, "_$1").toLowerCase().replace(/^_/, "");
|
|
1137
|
+
}
|
|
1138
|
+
function toDjangoPath(path) {
|
|
1139
|
+
return path.replace(/^\//, "").replace(/:(\w+)/g, "<str:$1>");
|
|
1140
|
+
}
|
|
1141
|
+
function pythonType(type) {
|
|
1142
|
+
switch (type) {
|
|
1143
|
+
case "number":
|
|
1144
|
+
return "int";
|
|
1145
|
+
case "boolean":
|
|
1146
|
+
return "bool";
|
|
1147
|
+
case "file":
|
|
1148
|
+
return "UploadedFile";
|
|
1149
|
+
default:
|
|
1150
|
+
return "str";
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// src/generators/fastapi-generator.ts
|
|
1155
|
+
var PY_ROUTE_MARKER2 = "# @serverbridge-route:";
|
|
1156
|
+
var PY_USER_START2 = "# @serverbridge-user-start";
|
|
1157
|
+
var PY_USER_END2 = "# @serverbridge-user-end";
|
|
1158
|
+
var FastAPIGenerator = class {
|
|
1159
|
+
framework = "fastapi";
|
|
1160
|
+
generate(routes, options) {
|
|
1161
|
+
const files = [];
|
|
1162
|
+
const warnings = [];
|
|
1163
|
+
if (routes.length === 0) {
|
|
1164
|
+
warnings.push("No routes to generate");
|
|
1165
|
+
return { files, preserved: [], warnings };
|
|
1166
|
+
}
|
|
1167
|
+
const groups = groupRoutesByPrefix(routes);
|
|
1168
|
+
for (const [groupKey, groupRoutes] of groups) {
|
|
1169
|
+
const content = this.generateRouterFile(groupRoutes, groupKey);
|
|
1170
|
+
const pyFilename = groupKey.replace(/-/g, "_");
|
|
1171
|
+
files.push({ path: `routers/${pyFilename}.py`, content, isNew: true });
|
|
1172
|
+
}
|
|
1173
|
+
const mainContent = this.generateMain(groups);
|
|
1174
|
+
files.push({ path: "main.py", content: mainContent, isNew: true });
|
|
1175
|
+
const allRoutes = [...groups.values()].flat();
|
|
1176
|
+
const routesWithBodies = allRoutes.filter(
|
|
1177
|
+
(r) => r.requestBody && r.requestBody.length > 0 && ["POST", "PUT", "PATCH"].includes(r.method)
|
|
1178
|
+
);
|
|
1179
|
+
if (routesWithBodies.length > 0) {
|
|
1180
|
+
const models = this.generateModels(routesWithBodies);
|
|
1181
|
+
files.push({ path: "models.py", content: models, isNew: true });
|
|
1182
|
+
}
|
|
1183
|
+
return { files, preserved: [], warnings };
|
|
1184
|
+
}
|
|
1185
|
+
generateRouterFile(routes, groupKey) {
|
|
1186
|
+
const lines = [];
|
|
1187
|
+
lines.push(`# Generated by @hyperfixi/server-bridge \u2014 DO NOT EDIT scaffold markers`);
|
|
1188
|
+
const needsQuery = routes.some((r) => r.queryParams && r.queryParams.length > 0);
|
|
1189
|
+
if (needsQuery) {
|
|
1190
|
+
lines.push(`from fastapi import APIRouter, Query`);
|
|
1191
|
+
} else {
|
|
1192
|
+
lines.push(`from fastapi import APIRouter`);
|
|
1193
|
+
}
|
|
1194
|
+
const routesWithBodies = routes.filter(
|
|
1195
|
+
(r) => r.requestBody && r.requestBody.length > 0 && ["POST", "PUT", "PATCH"].includes(r.method)
|
|
1196
|
+
);
|
|
1197
|
+
if (routesWithBodies.length > 0) {
|
|
1198
|
+
const modelNames = routesWithBodies.map((r) => modelName(r.handlerName));
|
|
1199
|
+
lines.push(`from ..models import ${modelNames.join(", ")}`);
|
|
1200
|
+
}
|
|
1201
|
+
const needsHtml = routes.some((r) => r.responseFormat === "html");
|
|
1202
|
+
const needsPlaintext = routes.some((r) => r.responseFormat === "text");
|
|
1203
|
+
if (needsHtml || needsPlaintext) {
|
|
1204
|
+
lines.push(`from fastapi.responses import HTMLResponse, PlainTextResponse`);
|
|
1205
|
+
}
|
|
1206
|
+
lines.push("");
|
|
1207
|
+
lines.push(`router = APIRouter()`);
|
|
1208
|
+
lines.push("");
|
|
1209
|
+
for (const route of routes) {
|
|
1210
|
+
lines.push("");
|
|
1211
|
+
lines.push(`${PY_ROUTE_MARKER2} ${route.method} ${route.path}`);
|
|
1212
|
+
lines.push(this.generateEndpoint(route));
|
|
1213
|
+
}
|
|
1214
|
+
lines.push("");
|
|
1215
|
+
return lines.join("\n");
|
|
1216
|
+
}
|
|
1217
|
+
generateEndpoint(route) {
|
|
1218
|
+
const lines = [];
|
|
1219
|
+
const method = route.method.toLowerCase();
|
|
1220
|
+
const funcName = toSnakeCase2(route.handlerName);
|
|
1221
|
+
const fastapiPath = toFastAPIPath(route.path);
|
|
1222
|
+
const extraArgs = [];
|
|
1223
|
+
if (route.responseFormat === "html") {
|
|
1224
|
+
extraArgs.push("response_class=HTMLResponse");
|
|
1225
|
+
} else if (route.responseFormat === "text") {
|
|
1226
|
+
extraArgs.push("response_class=PlainTextResponse");
|
|
1227
|
+
}
|
|
1228
|
+
const argsStr = extraArgs.length > 0 ? `, ${extraArgs.join(", ")}` : "";
|
|
1229
|
+
lines.push(`@router.${method}('${fastapiPath}'${argsStr})`);
|
|
1230
|
+
const params = [];
|
|
1231
|
+
for (const param of route.pathParams) {
|
|
1232
|
+
params.push(`${param}: str`);
|
|
1233
|
+
}
|
|
1234
|
+
if (route.queryParams && route.queryParams.length > 0) {
|
|
1235
|
+
for (const qp of route.queryParams) {
|
|
1236
|
+
params.push(`${qp.name}: str = Query(...)`);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
if (route.requestBody && route.requestBody.length > 0 && ["POST", "PUT", "PATCH"].includes(route.method)) {
|
|
1240
|
+
params.push(`body: ${modelName(route.handlerName)}`);
|
|
1241
|
+
}
|
|
1242
|
+
lines.push(`async def ${funcName}(${params.join(", ")}):`);
|
|
1243
|
+
lines.push(` ${PY_USER_START2}`);
|
|
1244
|
+
lines.push(` # TODO: Implement ${route.method} ${route.path}`);
|
|
1245
|
+
if (route.pathParams.length > 0) {
|
|
1246
|
+
lines.push(` # Path params: ${route.pathParams.join(", ")}`);
|
|
1247
|
+
}
|
|
1248
|
+
if (route.requestBody && route.requestBody.length > 0) {
|
|
1249
|
+
const fields = route.requestBody.map((f) => `${f.name}: ${pythonType2(f.type)}${f.required ? "" : " (optional)"}`).join(", ");
|
|
1250
|
+
lines.push(` # Expected body: { ${fields} }`);
|
|
1251
|
+
}
|
|
1252
|
+
for (const note of route.notes) {
|
|
1253
|
+
lines.push(` # Note: ${note}`);
|
|
1254
|
+
}
|
|
1255
|
+
if (route.responseFormat === "json") {
|
|
1256
|
+
lines.push(` return {'message': 'Not implemented'}`);
|
|
1257
|
+
} else if (route.responseFormat === "html") {
|
|
1258
|
+
lines.push(` return '<div>Not implemented</div>'`);
|
|
1259
|
+
} else {
|
|
1260
|
+
lines.push(` return 'Not implemented'`);
|
|
1261
|
+
}
|
|
1262
|
+
lines.push(` ${PY_USER_END2}`);
|
|
1263
|
+
return lines.join("\n");
|
|
1264
|
+
}
|
|
1265
|
+
generateMain(groups) {
|
|
1266
|
+
const lines = [];
|
|
1267
|
+
lines.push(`# Generated by @hyperfixi/server-bridge \u2014 DO NOT EDIT`);
|
|
1268
|
+
lines.push(`from fastapi import FastAPI`);
|
|
1269
|
+
for (const groupKey of groups.keys()) {
|
|
1270
|
+
const pyName = groupKey.replace(/-/g, "_");
|
|
1271
|
+
lines.push(`from .routers.${pyName} import router as ${pyName}_router`);
|
|
1272
|
+
}
|
|
1273
|
+
lines.push("");
|
|
1274
|
+
lines.push("app = FastAPI()");
|
|
1275
|
+
lines.push("");
|
|
1276
|
+
for (const groupKey of groups.keys()) {
|
|
1277
|
+
const pyName = groupKey.replace(/-/g, "_");
|
|
1278
|
+
lines.push(`app.include_router(${pyName}_router)`);
|
|
1279
|
+
}
|
|
1280
|
+
lines.push("");
|
|
1281
|
+
return lines.join("\n");
|
|
1282
|
+
}
|
|
1283
|
+
generateModels(routes) {
|
|
1284
|
+
const lines = [];
|
|
1285
|
+
lines.push(`# Generated by @hyperfixi/server-bridge \u2014 DO NOT EDIT`);
|
|
1286
|
+
lines.push(`from pydantic import BaseModel`);
|
|
1287
|
+
lines.push(`from typing import Optional`);
|
|
1288
|
+
lines.push("");
|
|
1289
|
+
for (const route of routes) {
|
|
1290
|
+
if (!route.requestBody || route.requestBody.length === 0) continue;
|
|
1291
|
+
lines.push("");
|
|
1292
|
+
lines.push(`class ${modelName(route.handlerName)}(BaseModel):`);
|
|
1293
|
+
for (const field of route.requestBody) {
|
|
1294
|
+
const pyType = pythonType2(field.type);
|
|
1295
|
+
if (field.required) {
|
|
1296
|
+
lines.push(` ${field.name}: ${pyType}`);
|
|
1297
|
+
} else {
|
|
1298
|
+
lines.push(` ${field.name}: Optional[${pyType}] = None`);
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
lines.push("");
|
|
1303
|
+
return lines.join("\n");
|
|
1304
|
+
}
|
|
1305
|
+
};
|
|
1306
|
+
function toSnakeCase2(str) {
|
|
1307
|
+
return str.replace(/([A-Z])/g, "_$1").toLowerCase().replace(/^_/, "");
|
|
1308
|
+
}
|
|
1309
|
+
function toFastAPIPath(path) {
|
|
1310
|
+
return path.replace(/:(\w+)/g, "{$1}");
|
|
1311
|
+
}
|
|
1312
|
+
function modelName(handlerName) {
|
|
1313
|
+
return handlerName.charAt(0).toUpperCase() + handlerName.slice(1) + "Body";
|
|
1314
|
+
}
|
|
1315
|
+
function pythonType2(type) {
|
|
1316
|
+
switch (type) {
|
|
1317
|
+
case "number":
|
|
1318
|
+
return "int";
|
|
1319
|
+
case "boolean":
|
|
1320
|
+
return "bool";
|
|
1321
|
+
case "file":
|
|
1322
|
+
return "bytes";
|
|
1323
|
+
default:
|
|
1324
|
+
return "str";
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// src/generators/index.ts
|
|
1329
|
+
function selectGenerator(framework) {
|
|
1330
|
+
switch (framework) {
|
|
1331
|
+
case "hono":
|
|
1332
|
+
return new HonoGenerator();
|
|
1333
|
+
case "openapi":
|
|
1334
|
+
return new OpenAPIGenerator();
|
|
1335
|
+
case "django":
|
|
1336
|
+
return new DjangoGenerator();
|
|
1337
|
+
case "fastapi":
|
|
1338
|
+
return new FastAPIGenerator();
|
|
1339
|
+
default:
|
|
1340
|
+
return new ExpressGenerator();
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// src/generators/manifest.ts
|
|
1345
|
+
var import_promises3 = require("fs/promises");
|
|
1346
|
+
var import_node_path3 = require("path");
|
|
1347
|
+
var import_node_crypto = require("crypto");
|
|
1348
|
+
var MANIFEST_FILENAME = "server-bridge.manifest.json";
|
|
1349
|
+
function hashRoute(route) {
|
|
1350
|
+
const data = JSON.stringify({
|
|
1351
|
+
path: route.path,
|
|
1352
|
+
method: route.method,
|
|
1353
|
+
responseFormat: route.responseFormat,
|
|
1354
|
+
pathParams: route.pathParams,
|
|
1355
|
+
queryParams: route.queryParams,
|
|
1356
|
+
requestBody: route.requestBody
|
|
1357
|
+
});
|
|
1358
|
+
return (0, import_node_crypto.createHash)("md5").update(data).digest("hex").slice(0, 8);
|
|
1359
|
+
}
|
|
1360
|
+
function manifestKey(method, path) {
|
|
1361
|
+
return `${method}:${path}`;
|
|
1362
|
+
}
|
|
1363
|
+
function createManifest(routes) {
|
|
1364
|
+
const manifest = {
|
|
1365
|
+
version: 1,
|
|
1366
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1367
|
+
routes: {}
|
|
1368
|
+
};
|
|
1369
|
+
for (const route of routes) {
|
|
1370
|
+
const key = manifestKey(route.method, route.path);
|
|
1371
|
+
manifest.routes[key] = {
|
|
1372
|
+
method: route.method,
|
|
1373
|
+
path: route.path,
|
|
1374
|
+
handlerName: route.handlerName,
|
|
1375
|
+
hash: hashRoute(route)
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
return manifest;
|
|
1379
|
+
}
|
|
1380
|
+
async function loadManifest(outputDir) {
|
|
1381
|
+
try {
|
|
1382
|
+
const content = await (0, import_promises3.readFile)((0, import_node_path3.join)(outputDir, MANIFEST_FILENAME), "utf-8");
|
|
1383
|
+
return JSON.parse(content);
|
|
1384
|
+
} catch {
|
|
1385
|
+
return null;
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
async function saveManifest(outputDir, manifest) {
|
|
1389
|
+
await (0, import_promises3.writeFile)(
|
|
1390
|
+
(0, import_node_path3.join)(outputDir, MANIFEST_FILENAME),
|
|
1391
|
+
JSON.stringify(manifest, null, 2) + "\n",
|
|
1392
|
+
"utf-8"
|
|
1393
|
+
);
|
|
1394
|
+
}
|
|
1395
|
+
function diffRoutes(oldManifest, newRoutes) {
|
|
1396
|
+
const oldRoutes = oldManifest?.routes ?? {};
|
|
1397
|
+
const added = [];
|
|
1398
|
+
const changed = [];
|
|
1399
|
+
const unchanged = [];
|
|
1400
|
+
for (const route of newRoutes) {
|
|
1401
|
+
const key = manifestKey(route.method, route.path);
|
|
1402
|
+
const existing = oldRoutes[key];
|
|
1403
|
+
if (!existing) {
|
|
1404
|
+
added.push(route);
|
|
1405
|
+
} else if (existing.hash !== hashRoute(route)) {
|
|
1406
|
+
changed.push(route);
|
|
1407
|
+
} else {
|
|
1408
|
+
unchanged.push(route);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
const newKeys = new Set(newRoutes.map((r) => manifestKey(r.method, r.path)));
|
|
1412
|
+
const removed = Object.values(oldRoutes).filter(
|
|
1413
|
+
(entry) => !newKeys.has(manifestKey(entry.method, entry.path))
|
|
1414
|
+
);
|
|
1415
|
+
return { added, removed, changed, unchanged };
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// src/cli/generate-command.ts
|
|
1419
|
+
async function runGenerate(options) {
|
|
1420
|
+
const cwd = (0, import_node_path4.resolve)(options.dir);
|
|
1421
|
+
const config = await loadConfig(cwd, {
|
|
1422
|
+
framework: options.framework,
|
|
1423
|
+
output: options.output,
|
|
1424
|
+
typescript: options.typescript,
|
|
1425
|
+
include: options.include,
|
|
1426
|
+
exclude: options.exclude,
|
|
1427
|
+
ignore: options.ignore
|
|
1428
|
+
});
|
|
1429
|
+
const result = await scanDirectory(cwd, {
|
|
1430
|
+
include: config.include,
|
|
1431
|
+
exclude: config.exclude,
|
|
1432
|
+
ignore: config.ignore
|
|
1433
|
+
});
|
|
1434
|
+
if (result.errors.length > 0) {
|
|
1435
|
+
for (const err of result.errors) {
|
|
1436
|
+
console.error(`Warning: ${err.file}: ${err.message}`);
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
if (result.conflicts.length > 0) {
|
|
1440
|
+
console.error(
|
|
1441
|
+
`
|
|
1442
|
+
Warning: ${result.conflicts.length} route conflict(s) detected:
|
|
1443
|
+
${formatConflicts(result.conflicts)}
|
|
1444
|
+
First occurrence used for generation.
|
|
1445
|
+
`
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
1448
|
+
if (result.routes.length === 0) {
|
|
1449
|
+
console.log("No routes found. Nothing to generate.");
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
const generator = selectGenerator(config.framework ?? "express");
|
|
1453
|
+
const outputDir = (0, import_node_path4.resolve)(cwd, config.output ?? "./server/routes");
|
|
1454
|
+
const oldManifest = options.overwrite ? null : await loadManifest(outputDir);
|
|
1455
|
+
const diff = diffRoutes(oldManifest, result.routes);
|
|
1456
|
+
const generated = generator.generate(result.routes, {
|
|
1457
|
+
outputDir,
|
|
1458
|
+
typescript: config.typescript,
|
|
1459
|
+
overwrite: options.overwrite
|
|
1460
|
+
});
|
|
1461
|
+
await (0, import_promises4.mkdir)(outputDir, { recursive: true });
|
|
1462
|
+
for (const file of generated.files) {
|
|
1463
|
+
const filePath = (0, import_node_path4.join)(outputDir, file.path);
|
|
1464
|
+
const fileDir = (0, import_node_path4.join)(filePath, "..");
|
|
1465
|
+
await (0, import_promises4.mkdir)(fileDir, { recursive: true });
|
|
1466
|
+
await (0, import_promises4.writeFile)(filePath, file.content, "utf-8");
|
|
1467
|
+
}
|
|
1468
|
+
const manifest = createManifest(result.routes);
|
|
1469
|
+
await saveManifest(outputDir, manifest);
|
|
1470
|
+
console.log(`Generated ${generated.files.length} file(s) in ${outputDir}/`);
|
|
1471
|
+
console.log(` Framework: ${generator.framework}`);
|
|
1472
|
+
console.log(` Routes: ${result.routes.length}`);
|
|
1473
|
+
if (diff.added.length > 0) {
|
|
1474
|
+
console.log(` New: ${diff.added.length}`);
|
|
1475
|
+
}
|
|
1476
|
+
if (diff.changed.length > 0) {
|
|
1477
|
+
console.log(` Changed: ${diff.changed.length}`);
|
|
1478
|
+
}
|
|
1479
|
+
if (diff.removed.length > 0) {
|
|
1480
|
+
console.log(` Removed: ${diff.removed.length} (routes no longer in source)`);
|
|
1481
|
+
}
|
|
1482
|
+
for (const warning of generated.warnings) {
|
|
1483
|
+
console.log(` Warning: ${warning}`);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
// src/cli/diff-command.ts
|
|
1488
|
+
var import_node_path5 = require("path");
|
|
1489
|
+
async function runDiff(options) {
|
|
1490
|
+
const cwd = (0, import_node_path5.resolve)(options.dir);
|
|
1491
|
+
const config = await loadConfig(cwd, {
|
|
1492
|
+
include: options.include,
|
|
1493
|
+
exclude: options.exclude,
|
|
1494
|
+
ignore: options.ignore
|
|
1495
|
+
});
|
|
1496
|
+
const outputDir = (0, import_node_path5.resolve)(cwd, config.output ?? "./server/routes");
|
|
1497
|
+
const manifest = await loadManifest(outputDir);
|
|
1498
|
+
if (!manifest) {
|
|
1499
|
+
console.log('No manifest found. Run "serverbridge generate" first.');
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
const result = await scanDirectory(cwd, {
|
|
1503
|
+
include: config.include,
|
|
1504
|
+
exclude: config.exclude,
|
|
1505
|
+
ignore: config.ignore
|
|
1506
|
+
});
|
|
1507
|
+
if (result.errors.length > 0) {
|
|
1508
|
+
for (const err of result.errors) {
|
|
1509
|
+
console.error(`Warning: ${err.file}: ${err.message}`);
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
if (result.conflicts.length > 0) {
|
|
1513
|
+
console.error(
|
|
1514
|
+
`
|
|
1515
|
+
Warning: ${result.conflicts.length} route conflict(s) detected:
|
|
1516
|
+
${formatConflicts(result.conflicts)}
|
|
1517
|
+
`
|
|
1518
|
+
);
|
|
1519
|
+
}
|
|
1520
|
+
const diff = diffRoutes(manifest, result.routes);
|
|
1521
|
+
const hasChanges = diff.added.length > 0 || diff.changed.length > 0 || diff.removed.length > 0;
|
|
1522
|
+
const total = diff.added.length + diff.changed.length + diff.removed.length + diff.unchanged.length;
|
|
1523
|
+
console.log(`
|
|
1524
|
+
Route Diff Summary:`);
|
|
1525
|
+
console.log(` Total: ${total}`);
|
|
1526
|
+
console.log(` Unchanged: ${diff.unchanged.length}`);
|
|
1527
|
+
console.log(` Added: ${diff.added.length}`);
|
|
1528
|
+
console.log(` Changed: ${diff.changed.length}`);
|
|
1529
|
+
console.log(` Removed: ${diff.removed.length}`);
|
|
1530
|
+
if (options.format === "detailed" && hasChanges) {
|
|
1531
|
+
console.log("");
|
|
1532
|
+
if (diff.added.length > 0) {
|
|
1533
|
+
console.log("Added:");
|
|
1534
|
+
for (const route of diff.added) {
|
|
1535
|
+
console.log(
|
|
1536
|
+
` + ${route.method} ${route.path} (${route.source.file}:${route.source.line ?? "?"})`
|
|
1537
|
+
);
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
if (diff.changed.length > 0) {
|
|
1541
|
+
console.log("Changed:");
|
|
1542
|
+
for (const route of diff.changed) {
|
|
1543
|
+
console.log(
|
|
1544
|
+
` ~ ${route.method} ${route.path} (${route.source.file}:${route.source.line ?? "?"})`
|
|
1545
|
+
);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
if (diff.removed.length > 0) {
|
|
1549
|
+
console.log("Removed:");
|
|
1550
|
+
for (const entry of diff.removed) {
|
|
1551
|
+
console.log(` - ${entry.method} ${entry.path}`);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
if (hasChanges) {
|
|
1556
|
+
console.log('\nChanges detected. Run "serverbridge generate" to update.');
|
|
1557
|
+
process.exitCode = 1;
|
|
1558
|
+
} else {
|
|
1559
|
+
console.log("\nNo changes detected.");
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// src/cli/watch-command.ts
|
|
1564
|
+
var import_node_fs = require("fs");
|
|
1565
|
+
var import_promises5 = require("fs/promises");
|
|
1566
|
+
var import_node_path6 = require("path");
|
|
1567
|
+
function patternToRegex(pattern) {
|
|
1568
|
+
const escaped = pattern.replace(/\./g, "\\.").replace(/\*\*\//g, "\0/").replace(/\*\*/g, "\0").replace(/\*/g, "[^/]*").replace(/\0\//g, "(.+/)?").replace(/\0/g, ".*");
|
|
1569
|
+
return new RegExp("^" + escaped + "$");
|
|
1570
|
+
}
|
|
1571
|
+
function shouldScan(filename, includePatterns) {
|
|
1572
|
+
return includePatterns.some((p) => patternToRegex(p).test(filename));
|
|
1573
|
+
}
|
|
1574
|
+
function shouldSkip(relPath, excludePatterns) {
|
|
1575
|
+
return excludePatterns.some((p) => patternToRegex(p).test(relPath));
|
|
1576
|
+
}
|
|
1577
|
+
function createDebouncedRunner(fn, ms) {
|
|
1578
|
+
let timer = null;
|
|
1579
|
+
let running = false;
|
|
1580
|
+
let queued = false;
|
|
1581
|
+
function trigger() {
|
|
1582
|
+
if (timer) clearTimeout(timer);
|
|
1583
|
+
timer = setTimeout(run, ms);
|
|
1584
|
+
}
|
|
1585
|
+
async function run() {
|
|
1586
|
+
timer = null;
|
|
1587
|
+
if (running) {
|
|
1588
|
+
queued = true;
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
running = true;
|
|
1592
|
+
try {
|
|
1593
|
+
await fn();
|
|
1594
|
+
} finally {
|
|
1595
|
+
running = false;
|
|
1596
|
+
if (queued) {
|
|
1597
|
+
queued = false;
|
|
1598
|
+
trigger();
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
return { trigger };
|
|
1603
|
+
}
|
|
1604
|
+
function computeRouteHash(routes) {
|
|
1605
|
+
return routes.map((r) => `${r.method}:${r.path}`).sort().join("\n");
|
|
1606
|
+
}
|
|
1607
|
+
async function runWatch(options) {
|
|
1608
|
+
const cwd = (0, import_node_path6.resolve)(options.dir);
|
|
1609
|
+
const config = await loadConfig(cwd, {
|
|
1610
|
+
framework: options.framework,
|
|
1611
|
+
output: options.output,
|
|
1612
|
+
typescript: options.typescript,
|
|
1613
|
+
include: options.include,
|
|
1614
|
+
exclude: options.exclude,
|
|
1615
|
+
ignore: options.ignore
|
|
1616
|
+
});
|
|
1617
|
+
const includePatterns = config.include ?? ["**/*.html", "**/*.htm"];
|
|
1618
|
+
const excludePatterns = config.exclude ?? ["node_modules/**", "dist/**"];
|
|
1619
|
+
const ignorePatterns = config.ignore ?? [];
|
|
1620
|
+
const debounceMs = options.debounce ?? 300;
|
|
1621
|
+
const outputDir = (0, import_node_path6.resolve)(cwd, config.output ?? "./server/routes");
|
|
1622
|
+
const framework = config.framework ?? "express";
|
|
1623
|
+
console.log(`
|
|
1624
|
+
Watching for changes in ${cwd}/`);
|
|
1625
|
+
console.log(` Output: ${outputDir}/`);
|
|
1626
|
+
console.log(` Framework: ${framework}`);
|
|
1627
|
+
console.log(` Press Ctrl+C to stop.
|
|
1628
|
+
`);
|
|
1629
|
+
const routesByFile = /* @__PURE__ */ new Map();
|
|
1630
|
+
let currentRouteHash = "";
|
|
1631
|
+
const initialScan = await scanDirectory(cwd, {
|
|
1632
|
+
include: config.include,
|
|
1633
|
+
exclude: config.exclude,
|
|
1634
|
+
ignore: config.ignore
|
|
1635
|
+
});
|
|
1636
|
+
for (const route of initialScan.routes) {
|
|
1637
|
+
const file = route.source.file;
|
|
1638
|
+
const existing = routesByFile.get(file) ?? [];
|
|
1639
|
+
existing.push(route);
|
|
1640
|
+
routesByFile.set(file, existing);
|
|
1641
|
+
}
|
|
1642
|
+
await generateFromCache();
|
|
1643
|
+
const changedFiles = /* @__PURE__ */ new Set();
|
|
1644
|
+
const debounced = createDebouncedRunner(async () => {
|
|
1645
|
+
const files = [...changedFiles];
|
|
1646
|
+
changedFiles.clear();
|
|
1647
|
+
await incrementalUpdate(files);
|
|
1648
|
+
}, debounceMs);
|
|
1649
|
+
const watcher = (0, import_node_fs.watch)(cwd, { recursive: true }, (_eventType, filename) => {
|
|
1650
|
+
if (!filename) return;
|
|
1651
|
+
const relPath = (0, import_node_path6.relative)(cwd, (0, import_node_path6.resolve)(cwd, filename));
|
|
1652
|
+
if (shouldSkip(relPath, excludePatterns)) return;
|
|
1653
|
+
if (!shouldScan(filename, includePatterns)) return;
|
|
1654
|
+
console.log(` Changed: ${relPath}`);
|
|
1655
|
+
changedFiles.add((0, import_node_path6.resolve)(cwd, filename));
|
|
1656
|
+
debounced.trigger();
|
|
1657
|
+
});
|
|
1658
|
+
process.on("SIGINT", () => {
|
|
1659
|
+
console.log("\nStopping watch mode...");
|
|
1660
|
+
watcher.close();
|
|
1661
|
+
process.exit(0);
|
|
1662
|
+
});
|
|
1663
|
+
await new Promise(() => {
|
|
1664
|
+
});
|
|
1665
|
+
async function incrementalUpdate(files) {
|
|
1666
|
+
try {
|
|
1667
|
+
for (const file of files) {
|
|
1668
|
+
try {
|
|
1669
|
+
const content = await (0, import_promises5.readFile)(file, "utf-8");
|
|
1670
|
+
const routes = scanRoutes(content, file);
|
|
1671
|
+
routesByFile.set(file, routes);
|
|
1672
|
+
} catch {
|
|
1673
|
+
routesByFile.delete(file);
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
await generateFromCache();
|
|
1677
|
+
} catch (err) {
|
|
1678
|
+
console.error("Generation failed:", err instanceof Error ? err.message : err);
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
async function generateFromCache() {
|
|
1682
|
+
let allRoutes = [];
|
|
1683
|
+
for (const routes2 of routesByFile.values()) {
|
|
1684
|
+
allRoutes.push(...routes2);
|
|
1685
|
+
}
|
|
1686
|
+
if (ignorePatterns.length > 0) {
|
|
1687
|
+
allRoutes = allRoutes.filter(
|
|
1688
|
+
(route) => !ignorePatterns.some((p) => matchIgnorePattern(route.path, p))
|
|
1689
|
+
);
|
|
1690
|
+
}
|
|
1691
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1692
|
+
for (const route of allRoutes) {
|
|
1693
|
+
const key = `${route.method}:${route.path}`;
|
|
1694
|
+
if (!seen.has(key)) seen.set(key, route);
|
|
1695
|
+
}
|
|
1696
|
+
const routes = [...seen.values()];
|
|
1697
|
+
const newHash = computeRouteHash(routes);
|
|
1698
|
+
if (newHash === currentRouteHash) {
|
|
1699
|
+
console.log(" No route changes detected, skipping generation.");
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
currentRouteHash = newHash;
|
|
1703
|
+
const conflicts = detectConflicts(allRoutes);
|
|
1704
|
+
if (conflicts.length > 0) {
|
|
1705
|
+
console.error(
|
|
1706
|
+
`
|
|
1707
|
+
Warning: ${conflicts.length} route conflict(s):
|
|
1708
|
+
${formatConflicts(conflicts)}`
|
|
1709
|
+
);
|
|
1710
|
+
}
|
|
1711
|
+
const generator = selectGenerator(framework);
|
|
1712
|
+
const generated = generator.generate(routes, {
|
|
1713
|
+
outputDir,
|
|
1714
|
+
typescript: config.typescript
|
|
1715
|
+
});
|
|
1716
|
+
await (0, import_promises5.mkdir)(outputDir, { recursive: true });
|
|
1717
|
+
for (const file of generated.files) {
|
|
1718
|
+
const filePath = (0, import_node_path6.join)(outputDir, file.path);
|
|
1719
|
+
const fileDir = (0, import_node_path6.join)(filePath, "..");
|
|
1720
|
+
await (0, import_promises5.mkdir)(fileDir, { recursive: true });
|
|
1721
|
+
await (0, import_promises5.writeFile)(filePath, file.content, "utf-8");
|
|
1722
|
+
}
|
|
1723
|
+
const manifest = createManifest(routes);
|
|
1724
|
+
await saveManifest(outputDir, manifest);
|
|
1725
|
+
console.log(` Generated ${generated.files.length} file(s) for ${routes.length} route(s)`);
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
// src/cli/index.ts
|
|
1730
|
+
var args = process.argv.slice(2);
|
|
1731
|
+
var command = args[0];
|
|
1732
|
+
function parseFlags(args2) {
|
|
1733
|
+
const flags = {};
|
|
1734
|
+
for (let i = 0; i < args2.length; i++) {
|
|
1735
|
+
const arg = args2[i];
|
|
1736
|
+
if (arg.startsWith("--")) {
|
|
1737
|
+
const [key, ...valueParts] = arg.slice(2).split("=");
|
|
1738
|
+
const value = valueParts.join("=");
|
|
1739
|
+
if (value) {
|
|
1740
|
+
flags[key] = value;
|
|
1741
|
+
} else if (key.startsWith("no-")) {
|
|
1742
|
+
flags[key.slice(3)] = false;
|
|
1743
|
+
} else {
|
|
1744
|
+
const next = args2[i + 1];
|
|
1745
|
+
if (next && !next.startsWith("--")) {
|
|
1746
|
+
flags[key] = next;
|
|
1747
|
+
i++;
|
|
1748
|
+
} else {
|
|
1749
|
+
flags[key] = true;
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
return flags;
|
|
1755
|
+
}
|
|
1756
|
+
function getPositionalArgs(args2) {
|
|
1757
|
+
return args2.filter((a) => !a.startsWith("--"));
|
|
1758
|
+
}
|
|
1759
|
+
function parseFramework(flags) {
|
|
1760
|
+
return ["hono", "openapi", "django", "fastapi"].includes(flags.framework) ? flags.framework : "express";
|
|
1761
|
+
}
|
|
1762
|
+
async function main() {
|
|
1763
|
+
if (!command || command === "--help" || command === "-h") {
|
|
1764
|
+
printHelp();
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
const flagArgs = args.slice(1);
|
|
1768
|
+
const flags = parseFlags(flagArgs);
|
|
1769
|
+
const positional = getPositionalArgs(flagArgs);
|
|
1770
|
+
const dir = positional[0] || ".";
|
|
1771
|
+
if (command === "scan") {
|
|
1772
|
+
await runScan({
|
|
1773
|
+
dir,
|
|
1774
|
+
format: flags.format === "table" ? "table" : "json",
|
|
1775
|
+
include: typeof flags.include === "string" ? flags.include.split(",") : void 0,
|
|
1776
|
+
exclude: typeof flags.exclude === "string" ? flags.exclude.split(",") : void 0,
|
|
1777
|
+
ignore: typeof flags.ignore === "string" ? flags.ignore.split(",") : void 0
|
|
1778
|
+
});
|
|
1779
|
+
} else if (command === "generate") {
|
|
1780
|
+
await runGenerate({
|
|
1781
|
+
dir,
|
|
1782
|
+
framework: parseFramework(flags),
|
|
1783
|
+
output: typeof flags.output === "string" ? flags.output : void 0,
|
|
1784
|
+
typescript: flags.typescript !== false,
|
|
1785
|
+
overwrite: flags.overwrite === true,
|
|
1786
|
+
include: typeof flags.include === "string" ? flags.include.split(",") : void 0,
|
|
1787
|
+
exclude: typeof flags.exclude === "string" ? flags.exclude.split(",") : void 0
|
|
1788
|
+
});
|
|
1789
|
+
} else if (command === "diff") {
|
|
1790
|
+
await runDiff({
|
|
1791
|
+
dir,
|
|
1792
|
+
format: flags.format === "detailed" ? "detailed" : "summary",
|
|
1793
|
+
include: typeof flags.include === "string" ? flags.include.split(",") : void 0,
|
|
1794
|
+
exclude: typeof flags.exclude === "string" ? flags.exclude.split(",") : void 0,
|
|
1795
|
+
ignore: typeof flags.ignore === "string" ? flags.ignore.split(",") : void 0
|
|
1796
|
+
});
|
|
1797
|
+
} else if (command === "watch") {
|
|
1798
|
+
await runWatch({
|
|
1799
|
+
dir,
|
|
1800
|
+
framework: parseFramework(flags),
|
|
1801
|
+
output: typeof flags.output === "string" ? flags.output : void 0,
|
|
1802
|
+
typescript: flags.typescript !== false,
|
|
1803
|
+
include: typeof flags.include === "string" ? flags.include.split(",") : void 0,
|
|
1804
|
+
exclude: typeof flags.exclude === "string" ? flags.exclude.split(",") : void 0,
|
|
1805
|
+
ignore: typeof flags.ignore === "string" ? flags.ignore.split(",") : void 0,
|
|
1806
|
+
debounce: typeof flags.debounce === "string" ? parseInt(flags.debounce, 10) : void 0
|
|
1807
|
+
});
|
|
1808
|
+
} else {
|
|
1809
|
+
console.error(`Unknown command: ${command}`);
|
|
1810
|
+
console.error('Run "serverbridge --help" for usage.');
|
|
1811
|
+
process.exit(1);
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
function printHelp() {
|
|
1815
|
+
console.log(`
|
|
1816
|
+
serverbridge - Extract routes from hyperscript/htmx HTML and generate server stubs
|
|
1817
|
+
|
|
1818
|
+
Usage:
|
|
1819
|
+
serverbridge scan [dir] [options] Scan files and print extracted routes
|
|
1820
|
+
serverbridge generate [dir] [options] Scan + generate server route stubs
|
|
1821
|
+
serverbridge diff [dir] [options] Show changes since last generation
|
|
1822
|
+
serverbridge watch [dir] [options] Watch files and auto-regenerate
|
|
1823
|
+
|
|
1824
|
+
Scan options:
|
|
1825
|
+
--format=json|table Output format (default: json)
|
|
1826
|
+
--include=PATTERNS Comma-separated glob patterns (default: **/*.{html,htm})
|
|
1827
|
+
--exclude=PATTERNS Comma-separated exclusion patterns
|
|
1828
|
+
--ignore=URLS Comma-separated URL patterns to ignore
|
|
1829
|
+
|
|
1830
|
+
Generate options:
|
|
1831
|
+
--framework=express|hono|openapi|django|fastapi Target framework (default: express)
|
|
1832
|
+
--output=DIR Output directory (default: ./server/routes)
|
|
1833
|
+
--typescript Generate TypeScript (default: true)
|
|
1834
|
+
--no-typescript Generate JavaScript
|
|
1835
|
+
--overwrite Overwrite existing user code
|
|
1836
|
+
|
|
1837
|
+
Diff options:
|
|
1838
|
+
--format=summary|detailed Output format (default: summary)
|
|
1839
|
+
|
|
1840
|
+
Watch options:
|
|
1841
|
+
--framework=... Same as generate
|
|
1842
|
+
--output=DIR Same as generate
|
|
1843
|
+
--debounce=MS Debounce delay in ms (default: 300)
|
|
1844
|
+
|
|
1845
|
+
Config file:
|
|
1846
|
+
Place .serverbridgerc.json in your project root to set defaults.
|
|
1847
|
+
`);
|
|
1848
|
+
}
|
|
1849
|
+
main().catch((err) => {
|
|
1850
|
+
console.error(err.message || err);
|
|
1851
|
+
process.exit(1);
|
|
1852
|
+
});
|
|
1853
|
+
//# sourceMappingURL=cli.cjs.map
|