@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/index.cjs
ADDED
|
@@ -0,0 +1,1426 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
DjangoGenerator: () => DjangoGenerator,
|
|
24
|
+
ExpressGenerator: () => ExpressGenerator,
|
|
25
|
+
FastAPIGenerator: () => FastAPIGenerator,
|
|
26
|
+
HonoGenerator: () => HonoGenerator,
|
|
27
|
+
OpenAPIGenerator: () => OpenAPIGenerator,
|
|
28
|
+
createManifest: () => createManifest,
|
|
29
|
+
detectConflicts: () => detectConflicts,
|
|
30
|
+
diffRoutes: () => diffRoutes,
|
|
31
|
+
extractFormBodies: () => extractFormBodies,
|
|
32
|
+
extractFormFields: () => extractFormFields,
|
|
33
|
+
extractHtmxRoutes: () => extractHtmxRoutes,
|
|
34
|
+
extractHyperscriptRoutes: () => extractHyperscriptRoutes,
|
|
35
|
+
extractPathParams: () => extractPathParams,
|
|
36
|
+
extractQueryParams: () => extractQueryParams,
|
|
37
|
+
formatConflicts: () => formatConflicts,
|
|
38
|
+
generate: () => generate,
|
|
39
|
+
inferConventions: () => inferConventions,
|
|
40
|
+
loadManifest: () => loadManifest,
|
|
41
|
+
normalizeUrl: () => normalizeUrl,
|
|
42
|
+
saveManifest: () => saveManifest,
|
|
43
|
+
scanDirectory: () => scanDirectory,
|
|
44
|
+
scanRoutes: () => scanRoutes,
|
|
45
|
+
selectGenerator: () => selectGenerator,
|
|
46
|
+
urlToHandlerName: () => urlToHandlerName
|
|
47
|
+
});
|
|
48
|
+
module.exports = __toCommonJS(index_exports);
|
|
49
|
+
|
|
50
|
+
// src/conventions/url-patterns.ts
|
|
51
|
+
function extractPathParams(url) {
|
|
52
|
+
const params = [];
|
|
53
|
+
for (const match of url.matchAll(/:(\w+)/g)) {
|
|
54
|
+
params.push(match[1]);
|
|
55
|
+
}
|
|
56
|
+
for (const match of url.matchAll(/\{(\w+)\}/g)) {
|
|
57
|
+
if (!params.includes(match[1])) {
|
|
58
|
+
params.push(match[1]);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return params;
|
|
62
|
+
}
|
|
63
|
+
function normalizeUrl(url) {
|
|
64
|
+
let normalized = url.split("?")[0].split("#")[0];
|
|
65
|
+
normalized = normalized.replace(/\{(\w+)\}/g, ":$1");
|
|
66
|
+
if (!normalized.startsWith("/")) {
|
|
67
|
+
normalized = "/" + normalized;
|
|
68
|
+
}
|
|
69
|
+
if (normalized.length > 1 && normalized.endsWith("/")) {
|
|
70
|
+
normalized = normalized.slice(0, -1);
|
|
71
|
+
}
|
|
72
|
+
return normalized;
|
|
73
|
+
}
|
|
74
|
+
function urlToHandlerName(url, method) {
|
|
75
|
+
const prefix = method.toLowerCase();
|
|
76
|
+
const segments = url.replace(/^\//, "").split("/").map((segment) => {
|
|
77
|
+
if (segment.startsWith(":")) {
|
|
78
|
+
const param = segment.slice(1);
|
|
79
|
+
return "By" + capitalize(param);
|
|
80
|
+
}
|
|
81
|
+
return segment.split(/[-_]/).map(capitalize).join("");
|
|
82
|
+
});
|
|
83
|
+
return prefix + segments.join("");
|
|
84
|
+
}
|
|
85
|
+
function extractQueryParams(url) {
|
|
86
|
+
const queryIndex = url.indexOf("?");
|
|
87
|
+
if (queryIndex === -1) return [];
|
|
88
|
+
const queryString = url.slice(queryIndex + 1).split("#")[0];
|
|
89
|
+
if (!queryString) return [];
|
|
90
|
+
const params = [];
|
|
91
|
+
const seen = /* @__PURE__ */ new Set();
|
|
92
|
+
for (const pair of queryString.split("&")) {
|
|
93
|
+
const eqIndex = pair.indexOf("=");
|
|
94
|
+
const rawName = eqIndex === -1 ? pair : pair.slice(0, eqIndex);
|
|
95
|
+
if (!rawName) continue;
|
|
96
|
+
let name;
|
|
97
|
+
try {
|
|
98
|
+
name = decodeURIComponent(rawName);
|
|
99
|
+
} catch {
|
|
100
|
+
name = rawName;
|
|
101
|
+
}
|
|
102
|
+
if (seen.has(name)) continue;
|
|
103
|
+
seen.add(name);
|
|
104
|
+
params.push({ name, type: "string", required: true });
|
|
105
|
+
}
|
|
106
|
+
return params;
|
|
107
|
+
}
|
|
108
|
+
function capitalize(s) {
|
|
109
|
+
if (!s) return "";
|
|
110
|
+
return s[0].toUpperCase() + s.slice(1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// src/conventions/convention-engine.ts
|
|
114
|
+
function inferConventions(url, context = {}) {
|
|
115
|
+
const notes = [];
|
|
116
|
+
let method;
|
|
117
|
+
if (context.explicitMethod) {
|
|
118
|
+
method = context.explicitMethod.toUpperCase();
|
|
119
|
+
} else if (context.elementTag === "form") {
|
|
120
|
+
method = "POST";
|
|
121
|
+
notes.push("Inferred POST from <form> context");
|
|
122
|
+
} else {
|
|
123
|
+
method = "GET";
|
|
124
|
+
}
|
|
125
|
+
let responseFormat;
|
|
126
|
+
if (context.fetchResponseType) {
|
|
127
|
+
const fmt = context.fetchResponseType.toLowerCase();
|
|
128
|
+
if (fmt === "json" || fmt === "html" || fmt === "text") {
|
|
129
|
+
responseFormat = fmt;
|
|
130
|
+
} else {
|
|
131
|
+
responseFormat = "json";
|
|
132
|
+
notes.push(`Unknown response type "${context.fetchResponseType}", defaulting to json`);
|
|
133
|
+
}
|
|
134
|
+
} else if (url.endsWith(".html") || url.includes("/pages/") || url.includes("/partials/")) {
|
|
135
|
+
responseFormat = "html";
|
|
136
|
+
notes.push("Inferred html response from URL pattern");
|
|
137
|
+
} else {
|
|
138
|
+
responseFormat = "json";
|
|
139
|
+
}
|
|
140
|
+
const handlerName = urlToHandlerName(url, method);
|
|
141
|
+
return { method, responseFormat, handlerName, notes };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// src/scanner/hyperscript-extractor.ts
|
|
145
|
+
var ATTR_PATTERNS = [
|
|
146
|
+
/_\s*=\s*"([^"]+)"/g,
|
|
147
|
+
/_\s*=\s*'([^']+)'/g,
|
|
148
|
+
/_\s*=\s*`([^`]+)`/g,
|
|
149
|
+
/_=\{`([^`]+)`\}/g,
|
|
150
|
+
// JSX template literal
|
|
151
|
+
/_=\{['"]([^'"]+)['"]\}/g
|
|
152
|
+
// JSX string
|
|
153
|
+
];
|
|
154
|
+
var SCRIPT_PATTERN = /<script[^>]*type=["']?text\/hyperscript["']?[^>]*>([\s\S]*?)<\/script>/gi;
|
|
155
|
+
var FETCH_COMMAND = /\bfetch\s+['"]?([^\s'"]+)['"]?(?:\s+as\s+(\w+))?(?:\s+via\s+(\w+))?/gi;
|
|
156
|
+
var SEND_FORM = /\bsend\s+(?:the\s+)?form\s+to\s+['"]?([^\s'"]+)['"]?(?:\s+as\s+(\w+))?/gi;
|
|
157
|
+
function extractHyperscriptRoutes(content, options) {
|
|
158
|
+
const routes = [];
|
|
159
|
+
const seen = /* @__PURE__ */ new Set();
|
|
160
|
+
const regions = extractHyperscriptRegions(content);
|
|
161
|
+
for (const region of regions) {
|
|
162
|
+
for (const match of region.code.matchAll(FETCH_COMMAND)) {
|
|
163
|
+
const rawUrl = match[1];
|
|
164
|
+
const responseType = match[2];
|
|
165
|
+
const method = match[3];
|
|
166
|
+
addRoute(routes, seen, rawUrl, {
|
|
167
|
+
file: options.file,
|
|
168
|
+
line: region.line,
|
|
169
|
+
kind: "fetch",
|
|
170
|
+
raw: match[0],
|
|
171
|
+
explicitMethod: method,
|
|
172
|
+
fetchResponseType: responseType
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
for (const match of region.code.matchAll(SEND_FORM)) {
|
|
176
|
+
const rawUrl = match[1];
|
|
177
|
+
const responseType = match[2];
|
|
178
|
+
addRoute(routes, seen, rawUrl, {
|
|
179
|
+
file: options.file,
|
|
180
|
+
line: region.line,
|
|
181
|
+
kind: "fetch",
|
|
182
|
+
raw: match[0],
|
|
183
|
+
explicitMethod: "POST",
|
|
184
|
+
fetchResponseType: responseType
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return routes;
|
|
189
|
+
}
|
|
190
|
+
function extractHyperscriptRegions(content) {
|
|
191
|
+
const regions = [];
|
|
192
|
+
const lines = content.split("\n");
|
|
193
|
+
for (const pattern of ATTR_PATTERNS) {
|
|
194
|
+
pattern.lastIndex = 0;
|
|
195
|
+
let match2;
|
|
196
|
+
while ((match2 = pattern.exec(content)) !== null) {
|
|
197
|
+
const line = lineNumberAt(content, match2.index);
|
|
198
|
+
regions.push({ code: match2[1], line });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
SCRIPT_PATTERN.lastIndex = 0;
|
|
202
|
+
let match;
|
|
203
|
+
while ((match = SCRIPT_PATTERN.exec(content)) !== null) {
|
|
204
|
+
const line = lineNumberAt(content, match.index);
|
|
205
|
+
regions.push({ code: match[1], line });
|
|
206
|
+
}
|
|
207
|
+
return regions;
|
|
208
|
+
}
|
|
209
|
+
function lineNumberAt(content, index) {
|
|
210
|
+
let line = 1;
|
|
211
|
+
for (let i = 0; i < index && i < content.length; i++) {
|
|
212
|
+
if (content[i] === "\n") line++;
|
|
213
|
+
}
|
|
214
|
+
return line;
|
|
215
|
+
}
|
|
216
|
+
function addRoute(routes, seen, rawUrl, options) {
|
|
217
|
+
if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://")) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (rawUrl.startsWith("{{") || rawUrl.startsWith("${")) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (!rawUrl.startsWith("/") && !rawUrl.includes("/")) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const queryParams = extractQueryParams(rawUrl);
|
|
227
|
+
const path = normalizeUrl(rawUrl);
|
|
228
|
+
const conventions = inferConventions(path, {
|
|
229
|
+
explicitMethod: options.explicitMethod,
|
|
230
|
+
fetchResponseType: options.fetchResponseType
|
|
231
|
+
});
|
|
232
|
+
const key = `${conventions.method}:${path}`;
|
|
233
|
+
if (seen.has(key)) return;
|
|
234
|
+
seen.add(key);
|
|
235
|
+
routes.push({
|
|
236
|
+
path,
|
|
237
|
+
method: conventions.method,
|
|
238
|
+
responseFormat: conventions.responseFormat,
|
|
239
|
+
source: {
|
|
240
|
+
file: options.file,
|
|
241
|
+
line: options.line,
|
|
242
|
+
kind: options.kind,
|
|
243
|
+
raw: options.raw
|
|
244
|
+
},
|
|
245
|
+
pathParams: extractPathParams(path),
|
|
246
|
+
...queryParams.length > 0 ? { queryParams } : {},
|
|
247
|
+
handlerName: conventions.handlerName,
|
|
248
|
+
notes: conventions.notes
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// src/scanner/htmx-extractor.ts
|
|
253
|
+
var HTMX_REQUEST_PATTERN = /\b(hx-get|hx-post|hx-put|hx-patch|hx-delete)\s*=\s*["']([^"']+)["']/gi;
|
|
254
|
+
var FIXI_ACTION_PATTERN = /\bfx-action\s*=\s*["']([^"']+)["']/gi;
|
|
255
|
+
var FIXI_METHOD_PATTERN = /\bfx-method\s*=\s*["'](GET|POST|PUT|PATCH|DELETE)["']/gi;
|
|
256
|
+
var HTMX_METHOD_MAP = {
|
|
257
|
+
"hx-get": "GET",
|
|
258
|
+
"hx-post": "POST",
|
|
259
|
+
"hx-put": "PUT",
|
|
260
|
+
"hx-patch": "PATCH",
|
|
261
|
+
"hx-delete": "DELETE"
|
|
262
|
+
};
|
|
263
|
+
function extractHtmxRoutes(content, options) {
|
|
264
|
+
const routes = [];
|
|
265
|
+
const seen = /* @__PURE__ */ new Set();
|
|
266
|
+
HTMX_REQUEST_PATTERN.lastIndex = 0;
|
|
267
|
+
let match;
|
|
268
|
+
while ((match = HTMX_REQUEST_PATTERN.exec(content)) !== null) {
|
|
269
|
+
const attrName = match[1].toLowerCase();
|
|
270
|
+
const rawUrl = match[2];
|
|
271
|
+
const method = HTMX_METHOD_MAP[attrName];
|
|
272
|
+
if (!method) continue;
|
|
273
|
+
addRoute2(routes, seen, rawUrl, {
|
|
274
|
+
file: options.file,
|
|
275
|
+
line: lineNumberAt2(content, match.index),
|
|
276
|
+
kind: "hx-attr",
|
|
277
|
+
raw: `${match[1]}="${match[2]}"`,
|
|
278
|
+
explicitMethod: method,
|
|
279
|
+
// htmx default response is html (server-rendered partials)
|
|
280
|
+
fetchResponseType: "html"
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
const fxMethods = collectFxMethods(content);
|
|
284
|
+
FIXI_ACTION_PATTERN.lastIndex = 0;
|
|
285
|
+
while ((match = FIXI_ACTION_PATTERN.exec(content)) !== null) {
|
|
286
|
+
const rawUrl = match[1];
|
|
287
|
+
const line = lineNumberAt2(content, match.index);
|
|
288
|
+
const nearbyMethod = findNearbyFxMethod(content, match.index, fxMethods);
|
|
289
|
+
addRoute2(routes, seen, rawUrl, {
|
|
290
|
+
file: options.file,
|
|
291
|
+
line,
|
|
292
|
+
kind: "fx-attr",
|
|
293
|
+
raw: `fx-action="${match[1]}"`,
|
|
294
|
+
explicitMethod: nearbyMethod
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
return routes;
|
|
298
|
+
}
|
|
299
|
+
function lineNumberAt2(content, index) {
|
|
300
|
+
let line = 1;
|
|
301
|
+
for (let i = 0; i < index && i < content.length; i++) {
|
|
302
|
+
if (content[i] === "\n") line++;
|
|
303
|
+
}
|
|
304
|
+
return line;
|
|
305
|
+
}
|
|
306
|
+
function collectFxMethods(content) {
|
|
307
|
+
const methods = [];
|
|
308
|
+
FIXI_METHOD_PATTERN.lastIndex = 0;
|
|
309
|
+
let match;
|
|
310
|
+
while ((match = FIXI_METHOD_PATTERN.exec(content)) !== null) {
|
|
311
|
+
methods.push({ method: match[1], index: match.index });
|
|
312
|
+
}
|
|
313
|
+
return methods;
|
|
314
|
+
}
|
|
315
|
+
function findNearbyFxMethod(content, actionIndex, fxMethods) {
|
|
316
|
+
for (const fm of fxMethods) {
|
|
317
|
+
if (Math.abs(fm.index - actionIndex) < 300) {
|
|
318
|
+
return fm.method;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return void 0;
|
|
322
|
+
}
|
|
323
|
+
function addRoute2(routes, seen, rawUrl, options) {
|
|
324
|
+
if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://")) {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const queryParams = extractQueryParams(rawUrl);
|
|
328
|
+
const path = normalizeUrl(rawUrl);
|
|
329
|
+
const conventions = inferConventions(path, {
|
|
330
|
+
explicitMethod: options.explicitMethod,
|
|
331
|
+
fetchResponseType: options.fetchResponseType
|
|
332
|
+
});
|
|
333
|
+
const key = `${conventions.method}:${path}`;
|
|
334
|
+
if (seen.has(key)) return;
|
|
335
|
+
seen.add(key);
|
|
336
|
+
routes.push({
|
|
337
|
+
path,
|
|
338
|
+
method: conventions.method,
|
|
339
|
+
responseFormat: conventions.responseFormat,
|
|
340
|
+
source: {
|
|
341
|
+
file: options.file,
|
|
342
|
+
line: options.line,
|
|
343
|
+
kind: options.kind,
|
|
344
|
+
raw: options.raw
|
|
345
|
+
},
|
|
346
|
+
pathParams: extractPathParams(path),
|
|
347
|
+
...queryParams.length > 0 ? { queryParams } : {},
|
|
348
|
+
handlerName: conventions.handlerName,
|
|
349
|
+
notes: conventions.notes
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// src/scanner/form-scanner.ts
|
|
354
|
+
var FORM_BLOCK = /<form\b[^>]*>([\s\S]*?)<\/form>/gi;
|
|
355
|
+
var NAMED_FIELD = /<(input|select|textarea)\b([^>]*)\bname\s*=\s*["']([^"']+)["']([^>]*)>/gi;
|
|
356
|
+
var TYPE_ATTR = /\btype\s*=\s*["']([^"']+)["']/i;
|
|
357
|
+
var REQUIRED_ATTR = /\brequired\b/i;
|
|
358
|
+
var TYPE_MAP = {
|
|
359
|
+
text: "string",
|
|
360
|
+
email: "string",
|
|
361
|
+
password: "string",
|
|
362
|
+
tel: "string",
|
|
363
|
+
url: "string",
|
|
364
|
+
search: "string",
|
|
365
|
+
hidden: "string",
|
|
366
|
+
color: "string",
|
|
367
|
+
date: "string",
|
|
368
|
+
"datetime-local": "string",
|
|
369
|
+
month: "string",
|
|
370
|
+
week: "string",
|
|
371
|
+
time: "string",
|
|
372
|
+
number: "number",
|
|
373
|
+
range: "number",
|
|
374
|
+
checkbox: "boolean",
|
|
375
|
+
file: "file"
|
|
376
|
+
};
|
|
377
|
+
function extractFormFields(formInnerHtml) {
|
|
378
|
+
const fields = [];
|
|
379
|
+
const seen = /* @__PURE__ */ new Set();
|
|
380
|
+
NAMED_FIELD.lastIndex = 0;
|
|
381
|
+
let match;
|
|
382
|
+
while ((match = NAMED_FIELD.exec(formInnerHtml)) !== null) {
|
|
383
|
+
const tag = match[1].toLowerCase();
|
|
384
|
+
const attrsBefore = match[2];
|
|
385
|
+
const name = match[3];
|
|
386
|
+
const attrsAfter = match[4];
|
|
387
|
+
const allAttrs = attrsBefore + " " + attrsAfter;
|
|
388
|
+
if (seen.has(name)) continue;
|
|
389
|
+
seen.add(name);
|
|
390
|
+
let type = "string";
|
|
391
|
+
if (tag === "select" || tag === "textarea") {
|
|
392
|
+
type = "string";
|
|
393
|
+
} else {
|
|
394
|
+
const typeMatch = allAttrs.match(TYPE_ATTR);
|
|
395
|
+
const inputType = typeMatch ? typeMatch[1].toLowerCase() : "text";
|
|
396
|
+
type = TYPE_MAP[inputType] ?? "unknown";
|
|
397
|
+
}
|
|
398
|
+
const required = REQUIRED_ATTR.test(allAttrs);
|
|
399
|
+
fields.push({ name, type, required });
|
|
400
|
+
}
|
|
401
|
+
return fields;
|
|
402
|
+
}
|
|
403
|
+
function extractFormBodies(content) {
|
|
404
|
+
const result = /* @__PURE__ */ new Map();
|
|
405
|
+
FORM_BLOCK.lastIndex = 0;
|
|
406
|
+
let match;
|
|
407
|
+
while ((match = FORM_BLOCK.exec(content)) !== null) {
|
|
408
|
+
const formTag = content.slice(
|
|
409
|
+
match.index,
|
|
410
|
+
match.index + content.slice(match.index).indexOf(">") + 1
|
|
411
|
+
);
|
|
412
|
+
const formBody = match[1];
|
|
413
|
+
const url = extractFormUrl(formTag);
|
|
414
|
+
if (!url) continue;
|
|
415
|
+
const fields = extractFormFields(formBody);
|
|
416
|
+
if (fields.length > 0) {
|
|
417
|
+
result.set(url, fields);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return result;
|
|
421
|
+
}
|
|
422
|
+
function extractFormUrl(formTag) {
|
|
423
|
+
const hxMatch = formTag.match(/\bhx-(?:post|put|patch)\s*=\s*["']([^"']+)["']/i);
|
|
424
|
+
if (hxMatch) return hxMatch[1];
|
|
425
|
+
const fxMatch = formTag.match(/\bfx-action\s*=\s*["']([^"']+)["']/i);
|
|
426
|
+
if (fxMatch) return fxMatch[1];
|
|
427
|
+
const hsMatch = formTag.match(/\bsend\s+(?:the\s+)?form\s+to\s+['"]?([^\s'"]+)['"]?/i);
|
|
428
|
+
if (hsMatch) return hsMatch[1];
|
|
429
|
+
return void 0;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// src/scanner/route-scanner.ts
|
|
433
|
+
var import_promises = require("fs/promises");
|
|
434
|
+
var import_glob = require("glob");
|
|
435
|
+
|
|
436
|
+
// src/scanner/conflict-detector.ts
|
|
437
|
+
function detectConflicts(routes) {
|
|
438
|
+
const warnings = [];
|
|
439
|
+
const groups = /* @__PURE__ */ new Map();
|
|
440
|
+
for (const route of routes) {
|
|
441
|
+
const key = `${route.method}:${route.path}`;
|
|
442
|
+
const existing = groups.get(key) ?? [];
|
|
443
|
+
existing.push(route);
|
|
444
|
+
groups.set(key, existing);
|
|
445
|
+
}
|
|
446
|
+
for (const [key, groupRoutes] of groups) {
|
|
447
|
+
if (groupRoutes.length < 2) continue;
|
|
448
|
+
const conflicts = [];
|
|
449
|
+
const formats = [...new Set(groupRoutes.map((r) => r.responseFormat))];
|
|
450
|
+
if (formats.length > 1) {
|
|
451
|
+
conflicts.push({
|
|
452
|
+
field: "responseFormat",
|
|
453
|
+
values: formats,
|
|
454
|
+
message: `Conflicting response formats: ${formats.join(" vs ")}`
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
if (["POST", "PUT", "PATCH"].includes(groupRoutes[0].method)) {
|
|
458
|
+
const bodySignatures = groupRoutes.map(
|
|
459
|
+
(r) => JSON.stringify((r.requestBody ?? []).map((f) => f.name).sort())
|
|
460
|
+
);
|
|
461
|
+
const uniqueSignatures = [...new Set(bodySignatures)];
|
|
462
|
+
if (uniqueSignatures.length > 1) {
|
|
463
|
+
conflicts.push({
|
|
464
|
+
field: "requestBody",
|
|
465
|
+
values: uniqueSignatures,
|
|
466
|
+
message: "Request body fields differ across declarations"
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
const querySignatures = groupRoutes.map(
|
|
471
|
+
(r) => JSON.stringify((r.queryParams ?? []).map((p) => p.name).sort())
|
|
472
|
+
);
|
|
473
|
+
const uniqueQuerySigs = [...new Set(querySignatures)];
|
|
474
|
+
if (uniqueQuerySigs.length > 1) {
|
|
475
|
+
conflicts.push({
|
|
476
|
+
field: "queryParams",
|
|
477
|
+
values: uniqueQuerySigs,
|
|
478
|
+
message: "Query parameters differ across declarations"
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
if (conflicts.length > 0) {
|
|
482
|
+
warnings.push({
|
|
483
|
+
routeKey: key,
|
|
484
|
+
conflicts,
|
|
485
|
+
sources: groupRoutes.map((r) => r.source)
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return warnings;
|
|
490
|
+
}
|
|
491
|
+
function formatConflicts(warnings) {
|
|
492
|
+
const lines = [];
|
|
493
|
+
for (const warning of warnings) {
|
|
494
|
+
lines.push(` ${warning.routeKey}:`);
|
|
495
|
+
for (const conflict of warning.conflicts) {
|
|
496
|
+
lines.push(` - ${conflict.message}`);
|
|
497
|
+
}
|
|
498
|
+
lines.push(` Sources:`);
|
|
499
|
+
for (const source of warning.sources) {
|
|
500
|
+
lines.push(` ${source.file}:${source.line ?? "?"} (${source.kind})`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return lines.join("\n");
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// src/scanner/route-scanner.ts
|
|
507
|
+
var DEFAULT_INCLUDE = ["**/*.html", "**/*.htm"];
|
|
508
|
+
var DEFAULT_EXCLUDE = ["**/node_modules/**", "**/dist/**", "**/.git/**"];
|
|
509
|
+
function scanRoutes(content, file) {
|
|
510
|
+
const hsRoutes = extractHyperscriptRoutes(content, { file });
|
|
511
|
+
const htmxRoutes = extractHtmxRoutes(content, { file });
|
|
512
|
+
const all = [...hsRoutes, ...htmxRoutes];
|
|
513
|
+
const deduped = deduplicateRoutes(all);
|
|
514
|
+
const formBodies = extractFormBodies(content);
|
|
515
|
+
for (const route of deduped) {
|
|
516
|
+
if (["POST", "PUT", "PATCH"].includes(route.method)) {
|
|
517
|
+
const rawUrl = route.path;
|
|
518
|
+
const fields = formBodies.get(rawUrl);
|
|
519
|
+
if (fields && fields.length > 0) {
|
|
520
|
+
route.requestBody = fields;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return deduped;
|
|
525
|
+
}
|
|
526
|
+
async function scanDirectory(dir, options = {}) {
|
|
527
|
+
const include = options.include ?? DEFAULT_INCLUDE;
|
|
528
|
+
const exclude = options.exclude ?? DEFAULT_EXCLUDE;
|
|
529
|
+
const files = [];
|
|
530
|
+
for (const pattern of include) {
|
|
531
|
+
const matches = await (0, import_glob.glob)(pattern, {
|
|
532
|
+
cwd: dir,
|
|
533
|
+
absolute: true,
|
|
534
|
+
ignore: exclude
|
|
535
|
+
});
|
|
536
|
+
files.push(...matches);
|
|
537
|
+
}
|
|
538
|
+
const uniqueFiles = [...new Set(files)];
|
|
539
|
+
const allRoutes = [];
|
|
540
|
+
const errors = [];
|
|
541
|
+
for (const file of uniqueFiles) {
|
|
542
|
+
try {
|
|
543
|
+
const content = await (0, import_promises.readFile)(file, "utf-8");
|
|
544
|
+
const routes = scanRoutes(content, file);
|
|
545
|
+
allRoutes.push(...routes);
|
|
546
|
+
} catch (err) {
|
|
547
|
+
errors.push({
|
|
548
|
+
file,
|
|
549
|
+
message: err instanceof Error ? err.message : String(err)
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
const conflicts = detectConflicts(allRoutes);
|
|
554
|
+
const deduped = deduplicateRoutes(allRoutes);
|
|
555
|
+
const ignored = options.ignore ?? [];
|
|
556
|
+
const filtered = deduped.filter(
|
|
557
|
+
(route) => !ignored.some((pattern) => matchIgnorePattern(route.path, pattern))
|
|
558
|
+
);
|
|
559
|
+
return {
|
|
560
|
+
routes: filtered,
|
|
561
|
+
filesScanned: uniqueFiles,
|
|
562
|
+
errors,
|
|
563
|
+
conflicts
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
function deduplicateRoutes(routes) {
|
|
567
|
+
const seen = /* @__PURE__ */ new Map();
|
|
568
|
+
for (const route of routes) {
|
|
569
|
+
const key = `${route.method}:${route.path}`;
|
|
570
|
+
if (!seen.has(key)) {
|
|
571
|
+
seen.set(key, route);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return [...seen.values()];
|
|
575
|
+
}
|
|
576
|
+
function matchIgnorePattern(path, pattern) {
|
|
577
|
+
if (pattern.endsWith("/**")) {
|
|
578
|
+
const prefix = pattern.slice(0, -3);
|
|
579
|
+
return path.startsWith(prefix);
|
|
580
|
+
}
|
|
581
|
+
return path === pattern;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// src/generators/template-helpers.ts
|
|
585
|
+
var ROUTE_MARKER = "// @serverbridge-route:";
|
|
586
|
+
var USER_START = "// @serverbridge-user-start";
|
|
587
|
+
var USER_END = "// @serverbridge-user-end";
|
|
588
|
+
function routeMarker(route) {
|
|
589
|
+
return `${ROUTE_MARKER} ${route.method} ${route.path}`;
|
|
590
|
+
}
|
|
591
|
+
function todoBlock(route, indent = " ") {
|
|
592
|
+
const lines = [];
|
|
593
|
+
lines.push(`${indent}// TODO: Implement ${route.method} ${route.path}`);
|
|
594
|
+
if (route.pathParams.length > 0) {
|
|
595
|
+
lines.push(`${indent}// Path params: ${route.pathParams.join(", ")}`);
|
|
596
|
+
}
|
|
597
|
+
if (route.queryParams && route.queryParams.length > 0) {
|
|
598
|
+
lines.push(`${indent}// Query params: ${route.queryParams.map((p) => p.name).join(", ")}`);
|
|
599
|
+
}
|
|
600
|
+
if (route.requestBody && route.requestBody.length > 0) {
|
|
601
|
+
const fields = route.requestBody.map((f) => `${f.name}: ${f.type}${f.required ? "" : "?"}`).join(", ");
|
|
602
|
+
lines.push(`${indent}// Expected body: { ${fields} }`);
|
|
603
|
+
}
|
|
604
|
+
if (route.notes.length > 0) {
|
|
605
|
+
for (const note of route.notes) {
|
|
606
|
+
lines.push(`${indent}// Note: ${note}`);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return lines.join("\n");
|
|
610
|
+
}
|
|
611
|
+
function groupRoutesByPrefix(routes) {
|
|
612
|
+
const groups = /* @__PURE__ */ new Map();
|
|
613
|
+
for (const route of routes) {
|
|
614
|
+
const segments = route.path.replace(/^\//, "").split("/");
|
|
615
|
+
const groupKey = segments.slice(0, Math.min(2, segments.length)).filter((s) => !s.startsWith(":")).join("-") || "root";
|
|
616
|
+
const group = groups.get(groupKey) ?? [];
|
|
617
|
+
group.push(route);
|
|
618
|
+
groups.set(groupKey, group);
|
|
619
|
+
}
|
|
620
|
+
return groups;
|
|
621
|
+
}
|
|
622
|
+
function groupToFilename(groupKey, typescript) {
|
|
623
|
+
return `${groupKey}.${typescript ? "ts" : "js"}`;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// src/generators/express-generator.ts
|
|
627
|
+
var ExpressGenerator = class {
|
|
628
|
+
framework = "express";
|
|
629
|
+
generate(routes, options) {
|
|
630
|
+
const ts = options.typescript !== false;
|
|
631
|
+
const files = [];
|
|
632
|
+
const warnings = [];
|
|
633
|
+
if (routes.length === 0) {
|
|
634
|
+
warnings.push("No routes to generate");
|
|
635
|
+
return { files, preserved: [], warnings };
|
|
636
|
+
}
|
|
637
|
+
const groups = groupRoutesByPrefix(routes);
|
|
638
|
+
for (const [groupKey, groupRoutes] of groups) {
|
|
639
|
+
const filename = groupToFilename(groupKey, ts);
|
|
640
|
+
const content = this.generateRouterFile(groupRoutes, groupKey, ts);
|
|
641
|
+
files.push({ path: `routes/${filename}`, content, isNew: true });
|
|
642
|
+
}
|
|
643
|
+
const indexContent = this.generateIndex(groups, ts);
|
|
644
|
+
files.push({ path: `routes/index.${ts ? "ts" : "js"}`, content: indexContent, isNew: true });
|
|
645
|
+
return { files, preserved: [], warnings };
|
|
646
|
+
}
|
|
647
|
+
generateRouterFile(routes, groupKey, ts) {
|
|
648
|
+
const lines = [];
|
|
649
|
+
lines.push(`// Generated by @hyperfixi/server-bridge \u2014 DO NOT EDIT scaffold markers`);
|
|
650
|
+
lines.push(`import { Router${ts ? ", Request, Response" : ""} } from 'express';`);
|
|
651
|
+
lines.push("");
|
|
652
|
+
lines.push("const router = Router();");
|
|
653
|
+
lines.push("");
|
|
654
|
+
for (const route of routes) {
|
|
655
|
+
lines.push(routeMarker(route));
|
|
656
|
+
lines.push(this.generateHandler(route, ts));
|
|
657
|
+
lines.push("");
|
|
658
|
+
}
|
|
659
|
+
lines.push("export default router;");
|
|
660
|
+
lines.push("");
|
|
661
|
+
return lines.join("\n");
|
|
662
|
+
}
|
|
663
|
+
generateHandler(route, ts) {
|
|
664
|
+
const method = route.method.toLowerCase();
|
|
665
|
+
const params = ts ? "(req: Request, res: Response)" : "(req, res)";
|
|
666
|
+
const lines = [];
|
|
667
|
+
lines.push(`router.${method}('${route.path}', async ${params} => {`);
|
|
668
|
+
lines.push(` ${USER_START}`);
|
|
669
|
+
lines.push(todoBlock(route, " "));
|
|
670
|
+
if (route.pathParams.length > 0) {
|
|
671
|
+
const paramList = route.pathParams.join(", ");
|
|
672
|
+
lines.push(` const { ${paramList} } = req.params;`);
|
|
673
|
+
}
|
|
674
|
+
if (route.queryParams && route.queryParams.length > 0) {
|
|
675
|
+
const paramList = route.queryParams.map((p) => p.name).join(", ");
|
|
676
|
+
lines.push(` const { ${paramList} } = req.query;`);
|
|
677
|
+
}
|
|
678
|
+
if (route.requestBody && route.requestBody.length > 0 && ["POST", "PUT", "PATCH"].includes(route.method)) {
|
|
679
|
+
const fieldList = route.requestBody.map((f) => f.name).join(", ");
|
|
680
|
+
lines.push(` const { ${fieldList} } = req.body;`);
|
|
681
|
+
}
|
|
682
|
+
if (route.responseFormat === "json") {
|
|
683
|
+
lines.push(` res.json({ message: 'Not implemented' });`);
|
|
684
|
+
} else if (route.responseFormat === "html") {
|
|
685
|
+
lines.push(` res.send('<div>Not implemented</div>');`);
|
|
686
|
+
} else {
|
|
687
|
+
lines.push(` res.send('Not implemented');`);
|
|
688
|
+
}
|
|
689
|
+
lines.push(` ${USER_END}`);
|
|
690
|
+
lines.push(`});`);
|
|
691
|
+
return lines.join("\n");
|
|
692
|
+
}
|
|
693
|
+
generateIndex(groups, ts) {
|
|
694
|
+
const lines = [];
|
|
695
|
+
lines.push(`// Generated by @hyperfixi/server-bridge \u2014 DO NOT EDIT`);
|
|
696
|
+
lines.push(`import { Router } from 'express';`);
|
|
697
|
+
for (const groupKey of groups.keys()) {
|
|
698
|
+
const filename = groupKey;
|
|
699
|
+
lines.push(`import ${toCamelCase(groupKey)}Router from './${filename}.js';`);
|
|
700
|
+
}
|
|
701
|
+
lines.push("");
|
|
702
|
+
lines.push("const router = Router();");
|
|
703
|
+
lines.push("");
|
|
704
|
+
for (const groupKey of groups.keys()) {
|
|
705
|
+
lines.push(`router.use(${toCamelCase(groupKey)}Router);`);
|
|
706
|
+
}
|
|
707
|
+
lines.push("");
|
|
708
|
+
lines.push("export default router;");
|
|
709
|
+
lines.push("");
|
|
710
|
+
return lines.join("\n");
|
|
711
|
+
}
|
|
712
|
+
};
|
|
713
|
+
function toCamelCase(str) {
|
|
714
|
+
return str.replace(/-(\w)/g, (_, c) => c.toUpperCase());
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// src/generators/hono-generator.ts
|
|
718
|
+
var HonoGenerator = class {
|
|
719
|
+
framework = "hono";
|
|
720
|
+
generate(routes, options) {
|
|
721
|
+
const ts = options.typescript !== false;
|
|
722
|
+
const files = [];
|
|
723
|
+
const warnings = [];
|
|
724
|
+
if (routes.length === 0) {
|
|
725
|
+
warnings.push("No routes to generate");
|
|
726
|
+
return { files, preserved: [], warnings };
|
|
727
|
+
}
|
|
728
|
+
const groups = groupRoutesByPrefix(routes);
|
|
729
|
+
for (const [groupKey, groupRoutes] of groups) {
|
|
730
|
+
const filename = groupToFilename(groupKey, ts);
|
|
731
|
+
const content = this.generateRouteFile(groupRoutes, groupKey, ts);
|
|
732
|
+
files.push({ path: `routes/${filename}`, content, isNew: true });
|
|
733
|
+
}
|
|
734
|
+
const indexContent = this.generateIndex(groups, ts);
|
|
735
|
+
files.push({ path: `routes/index.${ts ? "ts" : "js"}`, content: indexContent, isNew: true });
|
|
736
|
+
return { files, preserved: [], warnings };
|
|
737
|
+
}
|
|
738
|
+
generateRouteFile(routes, groupKey, ts) {
|
|
739
|
+
const lines = [];
|
|
740
|
+
lines.push(`// Generated by @hyperfixi/server-bridge \u2014 DO NOT EDIT scaffold markers`);
|
|
741
|
+
lines.push(`import { Hono } from 'hono';`);
|
|
742
|
+
lines.push("");
|
|
743
|
+
lines.push("const app = new Hono();");
|
|
744
|
+
lines.push("");
|
|
745
|
+
for (const route of routes) {
|
|
746
|
+
lines.push(routeMarker(route));
|
|
747
|
+
lines.push(this.generateHandler(route, ts));
|
|
748
|
+
lines.push("");
|
|
749
|
+
}
|
|
750
|
+
lines.push("export default app;");
|
|
751
|
+
lines.push("");
|
|
752
|
+
return lines.join("\n");
|
|
753
|
+
}
|
|
754
|
+
generateHandler(route, ts) {
|
|
755
|
+
const method = route.method.toLowerCase();
|
|
756
|
+
const lines = [];
|
|
757
|
+
lines.push(`app.${method}('${route.path}', async (c) => {`);
|
|
758
|
+
lines.push(` ${USER_START}`);
|
|
759
|
+
lines.push(todoBlock(route, " "));
|
|
760
|
+
if (route.pathParams.length > 0) {
|
|
761
|
+
for (const param of route.pathParams) {
|
|
762
|
+
lines.push(` const ${param} = c.req.param('${param}');`);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
if (route.queryParams && route.queryParams.length > 0) {
|
|
766
|
+
for (const param of route.queryParams) {
|
|
767
|
+
lines.push(` const ${param.name} = c.req.query('${param.name}');`);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
if (route.requestBody && route.requestBody.length > 0 && ["POST", "PUT", "PATCH"].includes(route.method)) {
|
|
771
|
+
lines.push(` const body = await c.req.json();`);
|
|
772
|
+
const fieldList = route.requestBody.map((f) => f.name).join(", ");
|
|
773
|
+
lines.push(` const { ${fieldList} } = body;`);
|
|
774
|
+
}
|
|
775
|
+
if (route.responseFormat === "json") {
|
|
776
|
+
lines.push(` return c.json({ message: 'Not implemented' });`);
|
|
777
|
+
} else if (route.responseFormat === "html") {
|
|
778
|
+
lines.push(` return c.html('<div>Not implemented</div>');`);
|
|
779
|
+
} else {
|
|
780
|
+
lines.push(` return c.text('Not implemented');`);
|
|
781
|
+
}
|
|
782
|
+
lines.push(` ${USER_END}`);
|
|
783
|
+
lines.push(`});`);
|
|
784
|
+
return lines.join("\n");
|
|
785
|
+
}
|
|
786
|
+
generateIndex(groups, ts) {
|
|
787
|
+
const lines = [];
|
|
788
|
+
lines.push(`// Generated by @hyperfixi/server-bridge \u2014 DO NOT EDIT`);
|
|
789
|
+
lines.push(`import { Hono } from 'hono';`);
|
|
790
|
+
for (const groupKey of groups.keys()) {
|
|
791
|
+
const filename = groupKey;
|
|
792
|
+
lines.push(`import ${toCamelCase2(groupKey)}Routes from './${filename}.js';`);
|
|
793
|
+
}
|
|
794
|
+
lines.push("");
|
|
795
|
+
lines.push("const app = new Hono();");
|
|
796
|
+
lines.push("");
|
|
797
|
+
for (const groupKey of groups.keys()) {
|
|
798
|
+
lines.push(`app.route('/', ${toCamelCase2(groupKey)}Routes);`);
|
|
799
|
+
}
|
|
800
|
+
lines.push("");
|
|
801
|
+
lines.push("export default app;");
|
|
802
|
+
lines.push("");
|
|
803
|
+
return lines.join("\n");
|
|
804
|
+
}
|
|
805
|
+
};
|
|
806
|
+
function toCamelCase2(str) {
|
|
807
|
+
return str.replace(/-(\w)/g, (_, c) => c.toUpperCase());
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// src/generators/openapi-generator.ts
|
|
811
|
+
var OpenAPIGenerator = class {
|
|
812
|
+
framework = "openapi";
|
|
813
|
+
generate(routes, options) {
|
|
814
|
+
const files = [];
|
|
815
|
+
const warnings = [];
|
|
816
|
+
if (routes.length === 0) {
|
|
817
|
+
warnings.push("No routes to generate");
|
|
818
|
+
return { files, preserved: [], warnings };
|
|
819
|
+
}
|
|
820
|
+
const spec = this.buildSpec(routes);
|
|
821
|
+
const yaml = this.toYaml(spec);
|
|
822
|
+
files.push({
|
|
823
|
+
path: "openapi.yaml",
|
|
824
|
+
content: yaml,
|
|
825
|
+
isNew: true
|
|
826
|
+
});
|
|
827
|
+
return { files, preserved: [], warnings };
|
|
828
|
+
}
|
|
829
|
+
buildSpec(routes) {
|
|
830
|
+
const paths = {};
|
|
831
|
+
for (const route of routes) {
|
|
832
|
+
const oaPath = route.path.replace(/:(\w+)/g, "{$1}");
|
|
833
|
+
const method = route.method.toLowerCase();
|
|
834
|
+
if (!paths[oaPath]) {
|
|
835
|
+
paths[oaPath] = {};
|
|
836
|
+
}
|
|
837
|
+
paths[oaPath][method] = this.buildOperation(route);
|
|
838
|
+
}
|
|
839
|
+
return {
|
|
840
|
+
openapi: "3.1.0",
|
|
841
|
+
info: {
|
|
842
|
+
title: "ServerBridge Generated API",
|
|
843
|
+
version: "0.1.0",
|
|
844
|
+
description: "Auto-extracted from hyperscript/htmx attributes"
|
|
845
|
+
},
|
|
846
|
+
paths
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
buildOperation(route) {
|
|
850
|
+
const op = {
|
|
851
|
+
operationId: route.handlerName,
|
|
852
|
+
summary: `Source: ${route.source.file}${route.source.line ? ":" + route.source.line : ""} (${route.source.kind})`,
|
|
853
|
+
responses: {
|
|
854
|
+
"200": this.buildResponse(route)
|
|
855
|
+
}
|
|
856
|
+
};
|
|
857
|
+
if (route.pathParams.length > 0) {
|
|
858
|
+
op.parameters = route.pathParams.map((name) => ({
|
|
859
|
+
name,
|
|
860
|
+
in: "path",
|
|
861
|
+
required: true,
|
|
862
|
+
schema: { type: inferParamType(name) }
|
|
863
|
+
}));
|
|
864
|
+
}
|
|
865
|
+
if (route.queryParams && route.queryParams.length > 0) {
|
|
866
|
+
const queryParamSpecs = route.queryParams.map((param) => ({
|
|
867
|
+
name: param.name,
|
|
868
|
+
in: "query",
|
|
869
|
+
required: param.required,
|
|
870
|
+
schema: { type: "string" }
|
|
871
|
+
}));
|
|
872
|
+
if (op.parameters) {
|
|
873
|
+
op.parameters.push(...queryParamSpecs);
|
|
874
|
+
} else {
|
|
875
|
+
op.parameters = queryParamSpecs;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
if (route.requestBody && route.requestBody.length > 0 && ["POST", "PUT", "PATCH"].includes(route.method)) {
|
|
879
|
+
const required = route.requestBody.filter((f) => f.required).map((f) => f.name);
|
|
880
|
+
const properties = {};
|
|
881
|
+
for (const field of route.requestBody) {
|
|
882
|
+
properties[field.name] = fieldToSchema(field.type);
|
|
883
|
+
}
|
|
884
|
+
op.requestBody = {
|
|
885
|
+
required: required.length > 0,
|
|
886
|
+
content: {
|
|
887
|
+
"application/json": {
|
|
888
|
+
schema: {
|
|
889
|
+
type: "object",
|
|
890
|
+
...required.length > 0 ? { required } : {},
|
|
891
|
+
properties
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
return op;
|
|
898
|
+
}
|
|
899
|
+
buildResponse(route) {
|
|
900
|
+
const contentType = route.responseFormat === "json" ? "application/json" : route.responseFormat === "html" ? "text/html" : "text/plain";
|
|
901
|
+
return {
|
|
902
|
+
description: "Success",
|
|
903
|
+
content: {
|
|
904
|
+
[contentType]: {
|
|
905
|
+
schema: route.responseFormat === "json" ? { type: "object" } : { type: "string" }
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Minimal YAML serializer — avoids adding a yaml dependency.
|
|
912
|
+
* Handles the specific nested structure of OpenAPI specs.
|
|
913
|
+
*/
|
|
914
|
+
toYaml(obj, indent = 0) {
|
|
915
|
+
const pad = " ".repeat(indent);
|
|
916
|
+
if (obj === null || obj === void 0) {
|
|
917
|
+
return "null";
|
|
918
|
+
}
|
|
919
|
+
if (typeof obj === "string") {
|
|
920
|
+
if (obj === "" || obj.includes(":") || obj.includes("#") || obj.includes("'") || /^\d/.test(obj) || obj === "true" || obj === "false") {
|
|
921
|
+
return `'${obj.replace(/'/g, "''")}'`;
|
|
922
|
+
}
|
|
923
|
+
return obj;
|
|
924
|
+
}
|
|
925
|
+
if (typeof obj === "number" || typeof obj === "boolean") {
|
|
926
|
+
return String(obj);
|
|
927
|
+
}
|
|
928
|
+
if (Array.isArray(obj)) {
|
|
929
|
+
if (obj.length === 0) return "[]";
|
|
930
|
+
if (obj.every((item) => typeof item === "string" || typeof item === "number")) {
|
|
931
|
+
return `[${obj.map((item) => typeof item === "string" ? `'${item}'` : String(item)).join(", ")}]`;
|
|
932
|
+
}
|
|
933
|
+
return obj.map((item) => {
|
|
934
|
+
if (typeof item === "object" && item !== null) {
|
|
935
|
+
const inner = this.toYaml(item, indent + 1).trimStart();
|
|
936
|
+
return `${pad}- ${inner}`;
|
|
937
|
+
}
|
|
938
|
+
return `${pad}- ${this.toYaml(item)}`;
|
|
939
|
+
}).join("\n");
|
|
940
|
+
}
|
|
941
|
+
if (typeof obj === "object") {
|
|
942
|
+
const entries = Object.entries(obj);
|
|
943
|
+
if (entries.length === 0) return "{}";
|
|
944
|
+
return entries.map(([key, value]) => {
|
|
945
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
946
|
+
const inner = this.toYaml(value, indent + 1);
|
|
947
|
+
return `${pad}${key}:
|
|
948
|
+
${inner}`;
|
|
949
|
+
}
|
|
950
|
+
if (Array.isArray(value) && value.length > 0 && value.some((v) => typeof v === "object")) {
|
|
951
|
+
const inner = this.toYaml(value, indent + 1);
|
|
952
|
+
return `${pad}${key}:
|
|
953
|
+
${inner}`;
|
|
954
|
+
}
|
|
955
|
+
return `${pad}${key}: ${this.toYaml(value, indent + 1)}`;
|
|
956
|
+
}).join("\n");
|
|
957
|
+
}
|
|
958
|
+
return String(obj);
|
|
959
|
+
}
|
|
960
|
+
};
|
|
961
|
+
function inferParamType(name) {
|
|
962
|
+
if (name === "id" || name.endsWith("Id")) return "string";
|
|
963
|
+
if (name === "page" || name === "limit" || name === "offset") return "integer";
|
|
964
|
+
return "string";
|
|
965
|
+
}
|
|
966
|
+
function fieldToSchema(type) {
|
|
967
|
+
switch (type) {
|
|
968
|
+
case "number":
|
|
969
|
+
return { type: "number" };
|
|
970
|
+
case "boolean":
|
|
971
|
+
return { type: "boolean" };
|
|
972
|
+
case "file":
|
|
973
|
+
return { type: "string", format: "binary" };
|
|
974
|
+
default:
|
|
975
|
+
return { type: "string" };
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// src/generators/django-generator.ts
|
|
980
|
+
var PY_ROUTE_MARKER = "# @serverbridge-route:";
|
|
981
|
+
var PY_USER_START = "# @serverbridge-user-start";
|
|
982
|
+
var PY_USER_END = "# @serverbridge-user-end";
|
|
983
|
+
var DjangoGenerator = class {
|
|
984
|
+
framework = "django";
|
|
985
|
+
generate(routes, options) {
|
|
986
|
+
const files = [];
|
|
987
|
+
const warnings = [];
|
|
988
|
+
if (routes.length === 0) {
|
|
989
|
+
warnings.push("No routes to generate");
|
|
990
|
+
return { files, preserved: [], warnings };
|
|
991
|
+
}
|
|
992
|
+
const groups = groupRoutesByPrefix(routes);
|
|
993
|
+
for (const [groupKey, groupRoutes] of groups) {
|
|
994
|
+
const views = this.generateViews(groupRoutes, groupKey);
|
|
995
|
+
files.push({ path: `${groupKey}/views.py`, content: views, isNew: true });
|
|
996
|
+
const urls = this.generateUrls(groupRoutes, groupKey);
|
|
997
|
+
files.push({ path: `${groupKey}/urls.py`, content: urls, isNew: true });
|
|
998
|
+
files.push({ path: `${groupKey}/__init__.py`, content: "", isNew: true });
|
|
999
|
+
}
|
|
1000
|
+
const rootUrls = this.generateRootUrls(groups);
|
|
1001
|
+
files.push({ path: "urls.py", content: rootUrls, isNew: true });
|
|
1002
|
+
return { files, preserved: [], warnings };
|
|
1003
|
+
}
|
|
1004
|
+
generateViews(routes, groupKey) {
|
|
1005
|
+
const lines = [];
|
|
1006
|
+
lines.push(`# Generated by @hyperfixi/server-bridge \u2014 DO NOT EDIT scaffold markers`);
|
|
1007
|
+
lines.push(`from django.http import JsonResponse, HttpResponse`);
|
|
1008
|
+
const needsJson = routes.some(
|
|
1009
|
+
(r) => r.requestBody && r.requestBody.length > 0 && ["POST", "PUT", "PATCH"].includes(r.method)
|
|
1010
|
+
);
|
|
1011
|
+
if (needsJson) {
|
|
1012
|
+
lines.push(`import json`);
|
|
1013
|
+
}
|
|
1014
|
+
const hasNonGet = routes.some((r) => r.method !== "GET");
|
|
1015
|
+
if (hasNonGet) {
|
|
1016
|
+
lines.push(`from django.views.decorators.http import require_http_methods`);
|
|
1017
|
+
}
|
|
1018
|
+
lines.push("");
|
|
1019
|
+
for (const route of routes) {
|
|
1020
|
+
lines.push("");
|
|
1021
|
+
lines.push(`${PY_ROUTE_MARKER} ${route.method} ${route.path}`);
|
|
1022
|
+
lines.push(this.generateView(route));
|
|
1023
|
+
}
|
|
1024
|
+
lines.push("");
|
|
1025
|
+
return lines.join("\n");
|
|
1026
|
+
}
|
|
1027
|
+
generateView(route) {
|
|
1028
|
+
const lines = [];
|
|
1029
|
+
const funcName = toSnakeCase(route.handlerName);
|
|
1030
|
+
if (route.method !== "GET") {
|
|
1031
|
+
lines.push(`@require_http_methods(["${route.method}"])`);
|
|
1032
|
+
}
|
|
1033
|
+
const params = ["request", ...route.pathParams];
|
|
1034
|
+
lines.push(`def ${funcName}(${params.join(", ")}):`);
|
|
1035
|
+
lines.push(` ${PY_USER_START}`);
|
|
1036
|
+
lines.push(` # TODO: Implement ${route.method} ${route.path}`);
|
|
1037
|
+
if (route.pathParams.length > 0) {
|
|
1038
|
+
lines.push(` # Path params: ${route.pathParams.join(", ")}`);
|
|
1039
|
+
}
|
|
1040
|
+
if (route.requestBody && route.requestBody.length > 0) {
|
|
1041
|
+
const fields = route.requestBody.map((f) => `${f.name}: ${pythonType(f.type)}${f.required ? "" : " (optional)"}`).join(", ");
|
|
1042
|
+
lines.push(` # Expected body: { ${fields} }`);
|
|
1043
|
+
}
|
|
1044
|
+
for (const note of route.notes) {
|
|
1045
|
+
lines.push(` # Note: ${note}`);
|
|
1046
|
+
}
|
|
1047
|
+
if (route.queryParams && route.queryParams.length > 0) {
|
|
1048
|
+
for (const param of route.queryParams) {
|
|
1049
|
+
lines.push(` ${param.name} = request.GET.get('${param.name}')`);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
if (route.requestBody && route.requestBody.length > 0 && ["POST", "PUT", "PATCH"].includes(route.method)) {
|
|
1053
|
+
lines.push(` data = json.loads(request.body)`);
|
|
1054
|
+
for (const field of route.requestBody) {
|
|
1055
|
+
const default_ = field.required ? "" : ", None";
|
|
1056
|
+
lines.push(` ${field.name} = data.get('${field.name}'${default_})`);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
if (route.responseFormat === "json") {
|
|
1060
|
+
lines.push(` return JsonResponse({'message': 'Not implemented'})`);
|
|
1061
|
+
} else if (route.responseFormat === "html") {
|
|
1062
|
+
lines.push(` return HttpResponse('<div>Not implemented</div>')`);
|
|
1063
|
+
} else {
|
|
1064
|
+
lines.push(` return HttpResponse('Not implemented', content_type='text/plain')`);
|
|
1065
|
+
}
|
|
1066
|
+
lines.push(` ${PY_USER_END}`);
|
|
1067
|
+
return lines.join("\n");
|
|
1068
|
+
}
|
|
1069
|
+
generateUrls(routes, groupKey) {
|
|
1070
|
+
const lines = [];
|
|
1071
|
+
lines.push(`# Generated by @hyperfixi/server-bridge \u2014 DO NOT EDIT`);
|
|
1072
|
+
lines.push(`from django.urls import path`);
|
|
1073
|
+
lines.push(`from . import views`);
|
|
1074
|
+
lines.push("");
|
|
1075
|
+
lines.push("urlpatterns = [");
|
|
1076
|
+
for (const route of routes) {
|
|
1077
|
+
const djangoPath = toDjangoPath(route.path);
|
|
1078
|
+
const funcName = toSnakeCase(route.handlerName);
|
|
1079
|
+
lines.push(` path('${djangoPath}', views.${funcName}, name='${funcName}'),`);
|
|
1080
|
+
}
|
|
1081
|
+
lines.push("]");
|
|
1082
|
+
lines.push("");
|
|
1083
|
+
return lines.join("\n");
|
|
1084
|
+
}
|
|
1085
|
+
generateRootUrls(groups) {
|
|
1086
|
+
const lines = [];
|
|
1087
|
+
lines.push(`# Generated by @hyperfixi/server-bridge \u2014 DO NOT EDIT`);
|
|
1088
|
+
lines.push(`from django.urls import path, include`);
|
|
1089
|
+
lines.push("");
|
|
1090
|
+
lines.push("urlpatterns = [");
|
|
1091
|
+
for (const groupKey of groups.keys()) {
|
|
1092
|
+
lines.push(` path('', include('${groupKey}.urls')),`);
|
|
1093
|
+
}
|
|
1094
|
+
lines.push("]");
|
|
1095
|
+
lines.push("");
|
|
1096
|
+
return lines.join("\n");
|
|
1097
|
+
}
|
|
1098
|
+
};
|
|
1099
|
+
function toSnakeCase(str) {
|
|
1100
|
+
return str.replace(/([A-Z])/g, "_$1").toLowerCase().replace(/^_/, "");
|
|
1101
|
+
}
|
|
1102
|
+
function toDjangoPath(path) {
|
|
1103
|
+
return path.replace(/^\//, "").replace(/:(\w+)/g, "<str:$1>");
|
|
1104
|
+
}
|
|
1105
|
+
function pythonType(type) {
|
|
1106
|
+
switch (type) {
|
|
1107
|
+
case "number":
|
|
1108
|
+
return "int";
|
|
1109
|
+
case "boolean":
|
|
1110
|
+
return "bool";
|
|
1111
|
+
case "file":
|
|
1112
|
+
return "UploadedFile";
|
|
1113
|
+
default:
|
|
1114
|
+
return "str";
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// src/generators/fastapi-generator.ts
|
|
1119
|
+
var PY_ROUTE_MARKER2 = "# @serverbridge-route:";
|
|
1120
|
+
var PY_USER_START2 = "# @serverbridge-user-start";
|
|
1121
|
+
var PY_USER_END2 = "# @serverbridge-user-end";
|
|
1122
|
+
var FastAPIGenerator = class {
|
|
1123
|
+
framework = "fastapi";
|
|
1124
|
+
generate(routes, options) {
|
|
1125
|
+
const files = [];
|
|
1126
|
+
const warnings = [];
|
|
1127
|
+
if (routes.length === 0) {
|
|
1128
|
+
warnings.push("No routes to generate");
|
|
1129
|
+
return { files, preserved: [], warnings };
|
|
1130
|
+
}
|
|
1131
|
+
const groups = groupRoutesByPrefix(routes);
|
|
1132
|
+
for (const [groupKey, groupRoutes] of groups) {
|
|
1133
|
+
const content = this.generateRouterFile(groupRoutes, groupKey);
|
|
1134
|
+
const pyFilename = groupKey.replace(/-/g, "_");
|
|
1135
|
+
files.push({ path: `routers/${pyFilename}.py`, content, isNew: true });
|
|
1136
|
+
}
|
|
1137
|
+
const mainContent = this.generateMain(groups);
|
|
1138
|
+
files.push({ path: "main.py", content: mainContent, isNew: true });
|
|
1139
|
+
const allRoutes = [...groups.values()].flat();
|
|
1140
|
+
const routesWithBodies = allRoutes.filter(
|
|
1141
|
+
(r) => r.requestBody && r.requestBody.length > 0 && ["POST", "PUT", "PATCH"].includes(r.method)
|
|
1142
|
+
);
|
|
1143
|
+
if (routesWithBodies.length > 0) {
|
|
1144
|
+
const models = this.generateModels(routesWithBodies);
|
|
1145
|
+
files.push({ path: "models.py", content: models, isNew: true });
|
|
1146
|
+
}
|
|
1147
|
+
return { files, preserved: [], warnings };
|
|
1148
|
+
}
|
|
1149
|
+
generateRouterFile(routes, groupKey) {
|
|
1150
|
+
const lines = [];
|
|
1151
|
+
lines.push(`# Generated by @hyperfixi/server-bridge \u2014 DO NOT EDIT scaffold markers`);
|
|
1152
|
+
const needsQuery = routes.some((r) => r.queryParams && r.queryParams.length > 0);
|
|
1153
|
+
if (needsQuery) {
|
|
1154
|
+
lines.push(`from fastapi import APIRouter, Query`);
|
|
1155
|
+
} else {
|
|
1156
|
+
lines.push(`from fastapi import APIRouter`);
|
|
1157
|
+
}
|
|
1158
|
+
const routesWithBodies = routes.filter(
|
|
1159
|
+
(r) => r.requestBody && r.requestBody.length > 0 && ["POST", "PUT", "PATCH"].includes(r.method)
|
|
1160
|
+
);
|
|
1161
|
+
if (routesWithBodies.length > 0) {
|
|
1162
|
+
const modelNames = routesWithBodies.map((r) => modelName(r.handlerName));
|
|
1163
|
+
lines.push(`from ..models import ${modelNames.join(", ")}`);
|
|
1164
|
+
}
|
|
1165
|
+
const needsHtml = routes.some((r) => r.responseFormat === "html");
|
|
1166
|
+
const needsPlaintext = routes.some((r) => r.responseFormat === "text");
|
|
1167
|
+
if (needsHtml || needsPlaintext) {
|
|
1168
|
+
lines.push(`from fastapi.responses import HTMLResponse, PlainTextResponse`);
|
|
1169
|
+
}
|
|
1170
|
+
lines.push("");
|
|
1171
|
+
lines.push(`router = APIRouter()`);
|
|
1172
|
+
lines.push("");
|
|
1173
|
+
for (const route of routes) {
|
|
1174
|
+
lines.push("");
|
|
1175
|
+
lines.push(`${PY_ROUTE_MARKER2} ${route.method} ${route.path}`);
|
|
1176
|
+
lines.push(this.generateEndpoint(route));
|
|
1177
|
+
}
|
|
1178
|
+
lines.push("");
|
|
1179
|
+
return lines.join("\n");
|
|
1180
|
+
}
|
|
1181
|
+
generateEndpoint(route) {
|
|
1182
|
+
const lines = [];
|
|
1183
|
+
const method = route.method.toLowerCase();
|
|
1184
|
+
const funcName = toSnakeCase2(route.handlerName);
|
|
1185
|
+
const fastapiPath = toFastAPIPath(route.path);
|
|
1186
|
+
const extraArgs = [];
|
|
1187
|
+
if (route.responseFormat === "html") {
|
|
1188
|
+
extraArgs.push("response_class=HTMLResponse");
|
|
1189
|
+
} else if (route.responseFormat === "text") {
|
|
1190
|
+
extraArgs.push("response_class=PlainTextResponse");
|
|
1191
|
+
}
|
|
1192
|
+
const argsStr = extraArgs.length > 0 ? `, ${extraArgs.join(", ")}` : "";
|
|
1193
|
+
lines.push(`@router.${method}('${fastapiPath}'${argsStr})`);
|
|
1194
|
+
const params = [];
|
|
1195
|
+
for (const param of route.pathParams) {
|
|
1196
|
+
params.push(`${param}: str`);
|
|
1197
|
+
}
|
|
1198
|
+
if (route.queryParams && route.queryParams.length > 0) {
|
|
1199
|
+
for (const qp of route.queryParams) {
|
|
1200
|
+
params.push(`${qp.name}: str = Query(...)`);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
if (route.requestBody && route.requestBody.length > 0 && ["POST", "PUT", "PATCH"].includes(route.method)) {
|
|
1204
|
+
params.push(`body: ${modelName(route.handlerName)}`);
|
|
1205
|
+
}
|
|
1206
|
+
lines.push(`async def ${funcName}(${params.join(", ")}):`);
|
|
1207
|
+
lines.push(` ${PY_USER_START2}`);
|
|
1208
|
+
lines.push(` # TODO: Implement ${route.method} ${route.path}`);
|
|
1209
|
+
if (route.pathParams.length > 0) {
|
|
1210
|
+
lines.push(` # Path params: ${route.pathParams.join(", ")}`);
|
|
1211
|
+
}
|
|
1212
|
+
if (route.requestBody && route.requestBody.length > 0) {
|
|
1213
|
+
const fields = route.requestBody.map((f) => `${f.name}: ${pythonType2(f.type)}${f.required ? "" : " (optional)"}`).join(", ");
|
|
1214
|
+
lines.push(` # Expected body: { ${fields} }`);
|
|
1215
|
+
}
|
|
1216
|
+
for (const note of route.notes) {
|
|
1217
|
+
lines.push(` # Note: ${note}`);
|
|
1218
|
+
}
|
|
1219
|
+
if (route.responseFormat === "json") {
|
|
1220
|
+
lines.push(` return {'message': 'Not implemented'}`);
|
|
1221
|
+
} else if (route.responseFormat === "html") {
|
|
1222
|
+
lines.push(` return '<div>Not implemented</div>'`);
|
|
1223
|
+
} else {
|
|
1224
|
+
lines.push(` return 'Not implemented'`);
|
|
1225
|
+
}
|
|
1226
|
+
lines.push(` ${PY_USER_END2}`);
|
|
1227
|
+
return lines.join("\n");
|
|
1228
|
+
}
|
|
1229
|
+
generateMain(groups) {
|
|
1230
|
+
const lines = [];
|
|
1231
|
+
lines.push(`# Generated by @hyperfixi/server-bridge \u2014 DO NOT EDIT`);
|
|
1232
|
+
lines.push(`from fastapi import FastAPI`);
|
|
1233
|
+
for (const groupKey of groups.keys()) {
|
|
1234
|
+
const pyName = groupKey.replace(/-/g, "_");
|
|
1235
|
+
lines.push(`from .routers.${pyName} import router as ${pyName}_router`);
|
|
1236
|
+
}
|
|
1237
|
+
lines.push("");
|
|
1238
|
+
lines.push("app = FastAPI()");
|
|
1239
|
+
lines.push("");
|
|
1240
|
+
for (const groupKey of groups.keys()) {
|
|
1241
|
+
const pyName = groupKey.replace(/-/g, "_");
|
|
1242
|
+
lines.push(`app.include_router(${pyName}_router)`);
|
|
1243
|
+
}
|
|
1244
|
+
lines.push("");
|
|
1245
|
+
return lines.join("\n");
|
|
1246
|
+
}
|
|
1247
|
+
generateModels(routes) {
|
|
1248
|
+
const lines = [];
|
|
1249
|
+
lines.push(`# Generated by @hyperfixi/server-bridge \u2014 DO NOT EDIT`);
|
|
1250
|
+
lines.push(`from pydantic import BaseModel`);
|
|
1251
|
+
lines.push(`from typing import Optional`);
|
|
1252
|
+
lines.push("");
|
|
1253
|
+
for (const route of routes) {
|
|
1254
|
+
if (!route.requestBody || route.requestBody.length === 0) continue;
|
|
1255
|
+
lines.push("");
|
|
1256
|
+
lines.push(`class ${modelName(route.handlerName)}(BaseModel):`);
|
|
1257
|
+
for (const field of route.requestBody) {
|
|
1258
|
+
const pyType = pythonType2(field.type);
|
|
1259
|
+
if (field.required) {
|
|
1260
|
+
lines.push(` ${field.name}: ${pyType}`);
|
|
1261
|
+
} else {
|
|
1262
|
+
lines.push(` ${field.name}: Optional[${pyType}] = None`);
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
lines.push("");
|
|
1267
|
+
return lines.join("\n");
|
|
1268
|
+
}
|
|
1269
|
+
};
|
|
1270
|
+
function toSnakeCase2(str) {
|
|
1271
|
+
return str.replace(/([A-Z])/g, "_$1").toLowerCase().replace(/^_/, "");
|
|
1272
|
+
}
|
|
1273
|
+
function toFastAPIPath(path) {
|
|
1274
|
+
return path.replace(/:(\w+)/g, "{$1}");
|
|
1275
|
+
}
|
|
1276
|
+
function modelName(handlerName) {
|
|
1277
|
+
return handlerName.charAt(0).toUpperCase() + handlerName.slice(1) + "Body";
|
|
1278
|
+
}
|
|
1279
|
+
function pythonType2(type) {
|
|
1280
|
+
switch (type) {
|
|
1281
|
+
case "number":
|
|
1282
|
+
return "int";
|
|
1283
|
+
case "boolean":
|
|
1284
|
+
return "bool";
|
|
1285
|
+
case "file":
|
|
1286
|
+
return "bytes";
|
|
1287
|
+
default:
|
|
1288
|
+
return "str";
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
// src/generators/index.ts
|
|
1293
|
+
function selectGenerator(framework) {
|
|
1294
|
+
switch (framework) {
|
|
1295
|
+
case "hono":
|
|
1296
|
+
return new HonoGenerator();
|
|
1297
|
+
case "openapi":
|
|
1298
|
+
return new OpenAPIGenerator();
|
|
1299
|
+
case "django":
|
|
1300
|
+
return new DjangoGenerator();
|
|
1301
|
+
case "fastapi":
|
|
1302
|
+
return new FastAPIGenerator();
|
|
1303
|
+
default:
|
|
1304
|
+
return new ExpressGenerator();
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
// src/generators/manifest.ts
|
|
1309
|
+
var import_promises2 = require("fs/promises");
|
|
1310
|
+
var import_node_path = require("path");
|
|
1311
|
+
var import_node_crypto = require("crypto");
|
|
1312
|
+
var MANIFEST_FILENAME = "server-bridge.manifest.json";
|
|
1313
|
+
function hashRoute(route) {
|
|
1314
|
+
const data = JSON.stringify({
|
|
1315
|
+
path: route.path,
|
|
1316
|
+
method: route.method,
|
|
1317
|
+
responseFormat: route.responseFormat,
|
|
1318
|
+
pathParams: route.pathParams,
|
|
1319
|
+
queryParams: route.queryParams,
|
|
1320
|
+
requestBody: route.requestBody
|
|
1321
|
+
});
|
|
1322
|
+
return (0, import_node_crypto.createHash)("md5").update(data).digest("hex").slice(0, 8);
|
|
1323
|
+
}
|
|
1324
|
+
function manifestKey(method, path) {
|
|
1325
|
+
return `${method}:${path}`;
|
|
1326
|
+
}
|
|
1327
|
+
function createManifest(routes) {
|
|
1328
|
+
const manifest = {
|
|
1329
|
+
version: 1,
|
|
1330
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1331
|
+
routes: {}
|
|
1332
|
+
};
|
|
1333
|
+
for (const route of routes) {
|
|
1334
|
+
const key = manifestKey(route.method, route.path);
|
|
1335
|
+
manifest.routes[key] = {
|
|
1336
|
+
method: route.method,
|
|
1337
|
+
path: route.path,
|
|
1338
|
+
handlerName: route.handlerName,
|
|
1339
|
+
hash: hashRoute(route)
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
return manifest;
|
|
1343
|
+
}
|
|
1344
|
+
async function loadManifest(outputDir) {
|
|
1345
|
+
try {
|
|
1346
|
+
const content = await (0, import_promises2.readFile)((0, import_node_path.join)(outputDir, MANIFEST_FILENAME), "utf-8");
|
|
1347
|
+
return JSON.parse(content);
|
|
1348
|
+
} catch {
|
|
1349
|
+
return null;
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
async function saveManifest(outputDir, manifest) {
|
|
1353
|
+
await (0, import_promises2.writeFile)(
|
|
1354
|
+
(0, import_node_path.join)(outputDir, MANIFEST_FILENAME),
|
|
1355
|
+
JSON.stringify(manifest, null, 2) + "\n",
|
|
1356
|
+
"utf-8"
|
|
1357
|
+
);
|
|
1358
|
+
}
|
|
1359
|
+
function diffRoutes(oldManifest, newRoutes) {
|
|
1360
|
+
const oldRoutes = oldManifest?.routes ?? {};
|
|
1361
|
+
const added = [];
|
|
1362
|
+
const changed = [];
|
|
1363
|
+
const unchanged = [];
|
|
1364
|
+
for (const route of newRoutes) {
|
|
1365
|
+
const key = manifestKey(route.method, route.path);
|
|
1366
|
+
const existing = oldRoutes[key];
|
|
1367
|
+
if (!existing) {
|
|
1368
|
+
added.push(route);
|
|
1369
|
+
} else if (existing.hash !== hashRoute(route)) {
|
|
1370
|
+
changed.push(route);
|
|
1371
|
+
} else {
|
|
1372
|
+
unchanged.push(route);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
const newKeys = new Set(newRoutes.map((r) => manifestKey(r.method, r.path)));
|
|
1376
|
+
const removed = Object.values(oldRoutes).filter(
|
|
1377
|
+
(entry) => !newKeys.has(manifestKey(entry.method, entry.path))
|
|
1378
|
+
);
|
|
1379
|
+
return { added, removed, changed, unchanged };
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// src/generate.ts
|
|
1383
|
+
var import_node_path2 = require("path");
|
|
1384
|
+
async function generate(options) {
|
|
1385
|
+
const cwd = (0, import_node_path2.resolve)(options.dir);
|
|
1386
|
+
const outputDir = (0, import_node_path2.resolve)(cwd, options.output ?? "./server/routes");
|
|
1387
|
+
const scan = await scanDirectory(cwd, {
|
|
1388
|
+
include: options.include,
|
|
1389
|
+
exclude: options.exclude,
|
|
1390
|
+
ignore: options.ignore
|
|
1391
|
+
});
|
|
1392
|
+
const generator = selectGenerator(options.framework ?? "express");
|
|
1393
|
+
const generated = generator.generate(scan.routes, {
|
|
1394
|
+
outputDir,
|
|
1395
|
+
typescript: options.typescript
|
|
1396
|
+
});
|
|
1397
|
+
return { scan, generated, routes: scan.routes };
|
|
1398
|
+
}
|
|
1399
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1400
|
+
0 && (module.exports = {
|
|
1401
|
+
DjangoGenerator,
|
|
1402
|
+
ExpressGenerator,
|
|
1403
|
+
FastAPIGenerator,
|
|
1404
|
+
HonoGenerator,
|
|
1405
|
+
OpenAPIGenerator,
|
|
1406
|
+
createManifest,
|
|
1407
|
+
detectConflicts,
|
|
1408
|
+
diffRoutes,
|
|
1409
|
+
extractFormBodies,
|
|
1410
|
+
extractFormFields,
|
|
1411
|
+
extractHtmxRoutes,
|
|
1412
|
+
extractHyperscriptRoutes,
|
|
1413
|
+
extractPathParams,
|
|
1414
|
+
extractQueryParams,
|
|
1415
|
+
formatConflicts,
|
|
1416
|
+
generate,
|
|
1417
|
+
inferConventions,
|
|
1418
|
+
loadManifest,
|
|
1419
|
+
normalizeUrl,
|
|
1420
|
+
saveManifest,
|
|
1421
|
+
scanDirectory,
|
|
1422
|
+
scanRoutes,
|
|
1423
|
+
selectGenerator,
|
|
1424
|
+
urlToHandlerName
|
|
1425
|
+
});
|
|
1426
|
+
//# sourceMappingURL=index.cjs.map
|