@burger-api/cli 0.7.1 → 0.9.1

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.
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Build config resolution: conventions-first with optional burger.config.ts
3
+ *
4
+ * Used by the CLI build pipeline to discover apiDir, pageDir, and prefixes
5
+ * without parsing the user's entry file.
6
+ */
7
+
8
+ import { existsSync } from 'fs';
9
+ import { join } from 'path';
10
+ import type { BuildConfig } from '../types/index';
11
+
12
+ const CONVENTION_DEFAULTS: BuildConfig = {
13
+ apiDir: './src/api',
14
+ pageDir: './src/pages',
15
+ apiPrefix: '/api',
16
+ pagePrefix: '/',
17
+ debug: false,
18
+ };
19
+
20
+ const CONFIG_NAMES = ['burger.config.ts', 'burger.config.js'];
21
+
22
+ /**
23
+ * Resolve build configuration from the project directory.
24
+ * Uses convention defaults; overrides with burger.config.ts / burger.config.js if present.
25
+ *
26
+ * @param cwd - Project root (e.g. process.cwd())
27
+ * @returns BuildConfig with resolved paths and prefixes
28
+ */
29
+ export async function resolveBuildConfig(cwd: string): Promise<BuildConfig> {
30
+ let configPath: string | null = null;
31
+ for (const name of CONFIG_NAMES) {
32
+ const candidate = join(cwd, name);
33
+ if (existsSync(candidate)) {
34
+ configPath = candidate;
35
+ break;
36
+ }
37
+ }
38
+
39
+ if (!configPath) {
40
+ return { ...CONVENTION_DEFAULTS };
41
+ }
42
+
43
+ try {
44
+ const mod = await import(configPath);
45
+ const user = mod.default ?? mod;
46
+ if (!user || typeof user !== 'object') {
47
+ return { ...CONVENTION_DEFAULTS };
48
+ }
49
+ return mergeBuildConfig(CONVENTION_DEFAULTS, user);
50
+ } catch (err) {
51
+ console.warn(
52
+ `[burger-api] Could not load ${configPath}: ${err instanceof Error ? err.message : String(err)}. Using convention defaults.`
53
+ );
54
+ return { ...CONVENTION_DEFAULTS };
55
+ }
56
+ }
57
+
58
+ function mergeBuildConfig(
59
+ defaults: BuildConfig,
60
+ user: Record<string, unknown>
61
+ ): BuildConfig {
62
+ return {
63
+ apiDir:
64
+ typeof user.apiDir === 'string' ? user.apiDir : defaults.apiDir,
65
+ pageDir:
66
+ typeof user.pageDir === 'string' ? user.pageDir : defaults.pageDir,
67
+ apiPrefix:
68
+ typeof user.apiPrefix === 'string'
69
+ ? user.apiPrefix
70
+ : defaults.apiPrefix,
71
+ pagePrefix:
72
+ typeof user.pagePrefix === 'string'
73
+ ? user.pagePrefix
74
+ : defaults.pagePrefix,
75
+ debug:
76
+ typeof user.debug === 'boolean' ? user.debug : defaults.debug,
77
+ };
78
+ }
@@ -0,0 +1,326 @@
1
+ import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'fs';
2
+ import { dirname, resolve } from 'path';
3
+
4
+ interface PreparedEntryOptions {
5
+ importPath?: string;
6
+ tempFilePath?: string;
7
+ }
8
+
9
+ const ENTRY_OPTIONS_FILENAME = '__burger_build_options__.ts';
10
+
11
+ /**
12
+ * Find the index of the closing ')' that matches the '(' at openIndex.
13
+ * Skips strings, template literals, and comments so parens inside them are ignored.
14
+ * Returns -1 if no matching ')' is found.
15
+ */
16
+ function findMatchingClosingParen(source: string, openIndex: number): number {
17
+ let depth = 1;
18
+ let inSingle = false;
19
+ let inDouble = false;
20
+ let inTemplate = false;
21
+ let interpolationBraceDepth = 0;
22
+ let nestedTemplateDepth = 0;
23
+ let inLineComment = false;
24
+ let inBlockComment = false;
25
+ let escaped = false;
26
+
27
+ for (let i = openIndex + 1; i < source.length; i++) {
28
+ const ch = source[i];
29
+ const next = i + 1 < source.length ? source[i + 1] : '';
30
+
31
+ if (inLineComment) {
32
+ if (ch === '\n') inLineComment = false;
33
+ continue;
34
+ }
35
+ if (inBlockComment) {
36
+ if (ch === '*' && next === '/') {
37
+ inBlockComment = false;
38
+ i++;
39
+ }
40
+ continue;
41
+ }
42
+
43
+ if (inSingle) {
44
+ if (!escaped && ch === "'") inSingle = false;
45
+ escaped = !escaped && ch === '\\';
46
+ continue;
47
+ }
48
+ if (inDouble) {
49
+ if (!escaped && ch === '"') inDouble = false;
50
+ escaped = !escaped && ch === '\\';
51
+ continue;
52
+ }
53
+ if (inTemplate) {
54
+ if (!escaped && ch === '`') {
55
+ if (nestedTemplateDepth > 0) {
56
+ nestedTemplateDepth--;
57
+ } else if (interpolationBraceDepth > 0) {
58
+ nestedTemplateDepth++;
59
+ } else {
60
+ inTemplate = false;
61
+ interpolationBraceDepth = 0;
62
+ nestedTemplateDepth = 0;
63
+ }
64
+ escaped = false;
65
+ continue;
66
+ }
67
+ if (!escaped && ch === '$' && next === '{') {
68
+ interpolationBraceDepth = 1;
69
+ i++;
70
+ escaped = false;
71
+ continue;
72
+ }
73
+ if (!escaped && nestedTemplateDepth === 0) {
74
+ if (ch === '{') {
75
+ interpolationBraceDepth++;
76
+ escaped = false;
77
+ continue;
78
+ }
79
+ if (ch === '}') {
80
+ interpolationBraceDepth--;
81
+ escaped = false;
82
+ continue;
83
+ }
84
+ }
85
+ if (!escaped && ch === '\\') {
86
+ escaped = true;
87
+ continue;
88
+ }
89
+ escaped = false;
90
+ continue;
91
+ }
92
+
93
+ if (ch === '/' && next === '/') {
94
+ inLineComment = true;
95
+ i++;
96
+ continue;
97
+ }
98
+ if (ch === '/' && next === '*') {
99
+ inBlockComment = true;
100
+ i++;
101
+ continue;
102
+ }
103
+ if (ch === "'") {
104
+ inSingle = true;
105
+ escaped = false;
106
+ continue;
107
+ }
108
+ if (ch === '"') {
109
+ inDouble = true;
110
+ escaped = false;
111
+ continue;
112
+ }
113
+ if (ch === '`') {
114
+ inTemplate = true;
115
+ interpolationBraceDepth = 0;
116
+ nestedTemplateDepth = 0;
117
+ escaped = false;
118
+ continue;
119
+ }
120
+
121
+ if (ch === '(') {
122
+ depth++;
123
+ continue;
124
+ }
125
+ if (ch === ')') {
126
+ depth--;
127
+ if (depth === 0) return i;
128
+ }
129
+ }
130
+
131
+ return -1;
132
+ }
133
+
134
+ function extractBurgerOptionsObjectLiteral(source: string): string | null {
135
+ const burgerCtor = source.match(/\bnew\s+Burger\s*\(/);
136
+ if (!burgerCtor || burgerCtor.index === undefined) {
137
+ return null;
138
+ }
139
+
140
+ const callStart = source.indexOf('(', burgerCtor.index);
141
+ if (callStart < 0) {
142
+ return null;
143
+ }
144
+
145
+ const callEnd = findMatchingClosingParen(source, callStart);
146
+ if (callEnd < 0) {
147
+ return null;
148
+ }
149
+
150
+ const objectStart = source.indexOf('{', callStart + 1);
151
+ if (objectStart < 0 || objectStart >= callEnd) {
152
+ return null;
153
+ }
154
+
155
+ let i = objectStart;
156
+ let depth = 0;
157
+ let inSingle = false;
158
+ let inDouble = false;
159
+ let inTemplate = false;
160
+ let interpolationBraceDepth = 0;
161
+ let nestedTemplateDepth = 0;
162
+ let inLineComment = false;
163
+ let inBlockComment = false;
164
+ let escaped = false;
165
+
166
+ for (; i < source.length; i++) {
167
+ const ch = source[i];
168
+ const next = i + 1 < source.length ? source[i + 1] : '';
169
+
170
+ if (inLineComment) {
171
+ if (ch === '\n') inLineComment = false;
172
+ continue;
173
+ }
174
+ if (inBlockComment) {
175
+ if (ch === '*' && next === '/') {
176
+ inBlockComment = false;
177
+ i++;
178
+ }
179
+ continue;
180
+ }
181
+
182
+ if (inSingle) {
183
+ if (!escaped && ch === "'") inSingle = false;
184
+ escaped = !escaped && ch === '\\';
185
+ continue;
186
+ }
187
+ if (inDouble) {
188
+ if (!escaped && ch === '"') inDouble = false;
189
+ escaped = !escaped && ch === '\\';
190
+ continue;
191
+ }
192
+ if (inTemplate) {
193
+ if (!escaped && ch === '`') {
194
+ if (nestedTemplateDepth > 0) {
195
+ nestedTemplateDepth--;
196
+ } else if (interpolationBraceDepth > 0) {
197
+ nestedTemplateDepth++;
198
+ } else {
199
+ inTemplate = false;
200
+ interpolationBraceDepth = 0;
201
+ nestedTemplateDepth = 0;
202
+ }
203
+ escaped = false;
204
+ continue;
205
+ }
206
+ if (!escaped && ch === '$' && next === '{') {
207
+ interpolationBraceDepth = 1;
208
+ i++;
209
+ escaped = false;
210
+ continue;
211
+ }
212
+ if (!escaped && nestedTemplateDepth === 0) {
213
+ if (ch === '{') {
214
+ interpolationBraceDepth++;
215
+ escaped = false;
216
+ continue;
217
+ }
218
+ if (ch === '}') {
219
+ interpolationBraceDepth--;
220
+ escaped = false;
221
+ continue;
222
+ }
223
+ }
224
+ if (!escaped && ch === '\\') {
225
+ escaped = true;
226
+ continue;
227
+ }
228
+ escaped = false;
229
+ continue;
230
+ }
231
+
232
+ if (ch === '/' && next === '/') {
233
+ inLineComment = true;
234
+ i++;
235
+ continue;
236
+ }
237
+ if (ch === '/' && next === '*') {
238
+ inBlockComment = true;
239
+ i++;
240
+ continue;
241
+ }
242
+ if (ch === "'") {
243
+ inSingle = true;
244
+ escaped = false;
245
+ continue;
246
+ }
247
+ if (ch === '"') {
248
+ inDouble = true;
249
+ escaped = false;
250
+ continue;
251
+ }
252
+ if (ch === '`') {
253
+ inTemplate = true;
254
+ interpolationBraceDepth = 0;
255
+ nestedTemplateDepth = 0;
256
+ escaped = false;
257
+ continue;
258
+ }
259
+
260
+ if (ch === '{') {
261
+ depth++;
262
+ continue;
263
+ }
264
+ if (ch === '}') {
265
+ depth--;
266
+ if (depth === 0) {
267
+ return source.slice(objectStart, i + 1);
268
+ }
269
+ }
270
+ }
271
+
272
+ return null;
273
+ }
274
+
275
+ export function prepareEntryOptionsModule(options: {
276
+ cwd: string;
277
+ entryFile: string;
278
+ }): PreparedEntryOptions {
279
+ const entryPath = resolve(options.cwd, options.entryFile);
280
+ if (!existsSync(entryPath)) {
281
+ throw new Error(`Entry file not found: ${options.entryFile}`);
282
+ }
283
+
284
+ const source = readFileSync(entryPath, 'utf-8');
285
+ const objectLiteral = extractBurgerOptionsObjectLiteral(source);
286
+
287
+ if (!objectLiteral) {
288
+ return {};
289
+ }
290
+
291
+ const burgerCtor = source.match(/\bnew\s+Burger\s*\(/);
292
+ const rawPrelude = source.slice(0, burgerCtor?.index ?? 0).trimEnd();
293
+ // Remove trailing partial assignment fragments like "const app ="
294
+ // when the constructor is assigned (e.g. const app = new Burger(...)).
295
+ const prelude = rawPrelude
296
+ .replace(
297
+ /(?:const|let|var)\s+[A-Za-z_$][A-Za-z0-9_$]*\s*(?::\s*.*?)?\s*=\s*$/,
298
+ ''
299
+ )
300
+ .replace(/\s*export\s+default\s*$/, '')
301
+ .trimEnd();
302
+ const tempFilePath = resolve(dirname(entryPath), ENTRY_OPTIONS_FILENAME);
303
+ const tempFileSource = [
304
+ '// Auto-generated by burger-api build. Do not edit.',
305
+ prelude,
306
+ '',
307
+ `export const burgerOptions = ${objectLiteral};`,
308
+ '',
309
+ ].join('\n');
310
+
311
+ writeFileSync(tempFilePath, tempFileSource, 'utf-8');
312
+
313
+ return {
314
+ importPath: tempFilePath.split('\\').join('/'),
315
+ tempFilePath,
316
+ };
317
+ }
318
+
319
+ export function cleanupEntryOptionsModule(tempFilePath?: string): void {
320
+ if (!tempFilePath) {
321
+ return;
322
+ }
323
+ if (existsSync(tempFilePath)) {
324
+ unlinkSync(tempFilePath);
325
+ }
326
+ }
@@ -403,23 +403,24 @@ const LOGO_TEXT = `
403
403
  ██╔╝ ██╔══██╗██║ ██║██╔══██╗██║ ╚██╗██╔══╝ ██╔══██╗██╔══██║██╔═══╝ ██║
404
404
  ██╔╝ ██████╦╝╚██████╔╝██║ ██║╚██████╔╝███████╗██║ ██║██║ ██║██║ ██║
405
405
  ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝
406
- CLI tool for BurgerAPI projects - v0.7.1
407
406
  `.trim();
408
407
 
409
408
  /**
410
409
  * Show ASCII art banner for BurgerAPI CLI
411
410
  * Displays when CLI starts
412
411
  * Uses ASCII-safe characters for Windows CMD compatibility
412
+ * @param version - CLI version (e.g. from package.json); defaults to '0.0.0'
413
413
  */
414
- export function showBanner(): void {
414
+ export function showBanner(version: string = '0.0.0'): void {
415
415
  const bannerColor = '\x1b[38;2;255;204;153m'; // Warm orange color
416
416
 
417
417
  const reset = '\x1b[0m';
418
+ const tagline = `CLI tool for BurgerAPI projects - v${version}`;
418
419
 
419
420
  // Unicode banner for modern terminals
420
421
  console.log(`${bannerColor}
421
- ${LOGO_TEXT}
422
-
422
+ ${LOGO_TEXT}
423
+ ${tagline}
423
424
  ${reset}`);
424
425
  }
425
426
 
@@ -0,0 +1,142 @@
1
+ import * as path from 'path';
2
+
3
+ /**
4
+ * Shared route conventions for CLI scanning and code generation.
5
+ * Must match framework routing rules (packages/burger-api pathConversion). Sync check: bun run test:route-sync (from repo root).
6
+ */
7
+ export const ROUTE_CONSTANTS = {
8
+ SUPPORTED_PAGE_EXTENSIONS: ['.tsx', '.html'],
9
+ PAGE_INDEX_FILES: ['index.tsx', 'index.html'],
10
+ DYNAMIC_SEGMENT_PREFIX: ':',
11
+ DYNAMIC_FOLDER_START: '[',
12
+ DYNAMIC_FOLDER_END: ']',
13
+ GROUPING_FOLDER_START: '(',
14
+ GROUPING_FOLDER_END: ')',
15
+ WILDCARD_SEGMENT_PREFIX: '*',
16
+ WILDCARD_SIMPLE: '[...]',
17
+ WILDCARD_START: '[...',
18
+ };
19
+
20
+ /**
21
+ * Cleans a prefix by removing leading and trailing slashes.
22
+ * @param prefix The prefix to clean.
23
+ * @returns The cleaned prefix.
24
+ */
25
+ function cleanPrefix(prefix: string): string {
26
+ let p = prefix;
27
+ while (p.startsWith('/')) p = p.slice(1);
28
+ while (p.endsWith('/')) p = p.slice(0, -1);
29
+ return p;
30
+ }
31
+
32
+ /**
33
+ * Converts a file path to an API route path.
34
+ * @param filePath The file path to convert.
35
+ * @param prefix The prefix to prepend to the route path.
36
+ * @returns The API route path.
37
+ */
38
+ export function filePathToApiRoutePath(
39
+ filePath: string,
40
+ prefix: string
41
+ ): string {
42
+ if (filePath.endsWith('route.ts')) {
43
+ filePath = filePath.slice(0, -'route.ts'.length);
44
+ }
45
+
46
+ const segments = filePath.split(path.sep);
47
+ const resultSegments: string[] = [];
48
+
49
+ for (const segment of segments) {
50
+ if (!segment) continue;
51
+ if (
52
+ segment.startsWith(ROUTE_CONSTANTS.GROUPING_FOLDER_START) &&
53
+ segment.endsWith(ROUTE_CONSTANTS.GROUPING_FOLDER_END)
54
+ ) {
55
+ continue;
56
+ }
57
+ if (
58
+ segment.startsWith(ROUTE_CONSTANTS.DYNAMIC_FOLDER_START) &&
59
+ segment.endsWith(ROUTE_CONSTANTS.DYNAMIC_FOLDER_END) &&
60
+ !segment.startsWith(ROUTE_CONSTANTS.WILDCARD_START)
61
+ ) {
62
+ resultSegments.push(
63
+ ROUTE_CONSTANTS.DYNAMIC_SEGMENT_PREFIX + segment.slice(1, -1)
64
+ );
65
+ } else if (segment === ROUTE_CONSTANTS.WILDCARD_SIMPLE) {
66
+ resultSegments.push(ROUTE_CONSTANTS.WILDCARD_SEGMENT_PREFIX);
67
+ } else {
68
+ resultSegments.push(segment);
69
+ }
70
+ }
71
+
72
+ let route = '/' + resultSegments.join('/');
73
+ const clean = cleanPrefix(prefix);
74
+ if (clean) {
75
+ route = '/' + clean + route;
76
+ }
77
+ if (route !== '/' && route.endsWith('/')) {
78
+ route = route.slice(0, -1);
79
+ }
80
+ return route;
81
+ }
82
+
83
+ /**
84
+ * Converts a file path to a page route path.
85
+ * @param filePath The file path to convert.
86
+ * @param prefix The prefix to prepend to the route path.
87
+ * @returns The page route path.
88
+ */
89
+ export function filePathToPageRoutePath(
90
+ filePath: string,
91
+ prefix: string
92
+ ): string {
93
+ const segments = filePath.split(path.sep);
94
+ const resultSegments: string[] = [];
95
+
96
+ for (const segment of segments) {
97
+ if (!segment) continue;
98
+ if (
99
+ segment.startsWith(ROUTE_CONSTANTS.GROUPING_FOLDER_START) &&
100
+ segment.endsWith(ROUTE_CONSTANTS.GROUPING_FOLDER_END)
101
+ ) {
102
+ continue;
103
+ }
104
+ if (
105
+ segment.startsWith(ROUTE_CONSTANTS.DYNAMIC_FOLDER_START) &&
106
+ segment.endsWith(ROUTE_CONSTANTS.DYNAMIC_FOLDER_END)
107
+ ) {
108
+ resultSegments.push(
109
+ ROUTE_CONSTANTS.DYNAMIC_SEGMENT_PREFIX + segment.slice(1, -1)
110
+ );
111
+ } else {
112
+ resultSegments.push(segment);
113
+ }
114
+ }
115
+
116
+ let route = '/' + resultSegments.join('/');
117
+ const clean = cleanPrefix(prefix);
118
+ if (clean) {
119
+ route = '/' + clean + route;
120
+ }
121
+ if (route !== '/' && route.endsWith('/')) {
122
+ route = route.slice(0, -1);
123
+ }
124
+
125
+ const pathSegments = route.split('/');
126
+ const lastSegment = pathSegments.at(-1);
127
+ if (typeof lastSegment === 'string') {
128
+ if (ROUTE_CONSTANTS.PAGE_INDEX_FILES.includes(lastSegment)) {
129
+ pathSegments.pop();
130
+ } else {
131
+ const extensionIndex = lastSegment.lastIndexOf('.');
132
+ if (extensionIndex > 0) {
133
+ pathSegments[pathSegments.length - 1] = lastSegment.slice(
134
+ 0,
135
+ extensionIndex
136
+ );
137
+ }
138
+ }
139
+ }
140
+
141
+ return pathSegments.join('/') === '' ? '/' : pathSegments.join('/');
142
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Build-time detection of which HTTP methods a route module exports.
3
+ * Used so the virtual entry only emits handler keys for methods that exist.
4
+ */
5
+
6
+ import { readFile } from 'fs/promises';
7
+
8
+ const HTTP_METHOD_NAMES = [
9
+ 'GET',
10
+ 'POST',
11
+ 'PUT',
12
+ 'DELETE',
13
+ 'PATCH',
14
+ 'HEAD',
15
+ 'OPTIONS',
16
+ ] as const;
17
+
18
+ /** Matches export async function GET( or export function POST( */
19
+ const EXPORT_FUNCTION_RE =
20
+ /export\s+(?:async\s+)?function\s+(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s*\(/g;
21
+
22
+ /** Matches export { ... } and captures the content between braces */
23
+ const EXPORT_NAMED_BLOCK_RE = /export\s*\{([^}]*)\}/g;
24
+
25
+ /** Matches export const GET = ... or export const POST = async (req) => ... */
26
+ const EXPORT_CONST_RE =
27
+ /export\s+const\s+(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s*=/g;
28
+
29
+ /** Matches a single HTTP method name (used to find all methods inside a block) */
30
+ const METHOD_NAME_RE = /\b(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\b/g;
31
+
32
+ /**
33
+ * Strip comments so export regexes do not match inside comments.
34
+ * Removes multi-line comments (/* ... *\/) and lines that are only a single-line comment (// ...).
35
+ */
36
+ function stripComments(content: string): string {
37
+ let out = content.replace(/\/\*[\s\S]*?\*\//g, ' ');
38
+ out = out.replace(/^\s*\/\/[^\n]*$/gm, '\n');
39
+ return out;
40
+ }
41
+
42
+ /**
43
+ * Detect which HTTP methods are exported from a route file.
44
+ * Reads the file and looks for export function METHOD(, export const METHOD = ..., and export { ... METHOD ... }.
45
+ *
46
+ * @param filePath - Absolute path to the route file (e.g. route.ts).
47
+ * @returns Array of method names found, or undefined if file could not be read or parsed.
48
+ */
49
+ export async function detectExportedMethods(
50
+ filePath: string
51
+ ): Promise<string[] | undefined> {
52
+ let content: string;
53
+ try {
54
+ content = await readFile(filePath, 'utf-8');
55
+ } catch {
56
+ return undefined;
57
+ }
58
+
59
+ const contentWithoutComments = stripComments(content);
60
+ const found = new Set<string>();
61
+
62
+ let match: RegExpExecArray | null;
63
+ EXPORT_FUNCTION_RE.lastIndex = 0;
64
+ while ((match = EXPORT_FUNCTION_RE.exec(contentWithoutComments)) !== null) {
65
+ const name = match[1];
66
+ if (name) found.add(name);
67
+ }
68
+
69
+ EXPORT_CONST_RE.lastIndex = 0;
70
+ while ((match = EXPORT_CONST_RE.exec(contentWithoutComments)) !== null) {
71
+ const name = match[1];
72
+ if (name) found.add(name);
73
+ }
74
+
75
+ // Scan each export { ... } block and collect all HTTP method names inside it
76
+ EXPORT_NAMED_BLOCK_RE.lastIndex = 0;
77
+ while ((match = EXPORT_NAMED_BLOCK_RE.exec(contentWithoutComments)) !== null) {
78
+ const blockContent = match[1] ?? '';
79
+ let methodMatch: RegExpExecArray | null;
80
+ METHOD_NAME_RE.lastIndex = 0;
81
+ while ((methodMatch = METHOD_NAME_RE.exec(blockContent)) !== null) {
82
+ const name = methodMatch[1];
83
+ if (name) found.add(name);
84
+ }
85
+ }
86
+
87
+ const methods = [...found].filter((m) =>
88
+ (HTTP_METHOD_NAMES as readonly string[]).includes(m)
89
+ );
90
+ return methods.length > 0 ? methods : undefined;
91
+ }