froggy-docs 1.1.1 → 1.1.4

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,123 @@
1
+ import 'dart:io';
2
+ import 'package:args/args.dart';
3
+ import 'package:froggy_docs/src/watcher_engine.dart';
4
+ import 'package:froggy_docs/src/web_server.dart';
5
+ import 'package:froggy_docs/src/parser_engine.dart';
6
+
7
+ class CliRunner {
8
+ void run(List<String> arguments) async {
9
+ final parser = ArgParser();
10
+ parser.addCommand('watch');
11
+ parser.addCommand('serve');
12
+ parser.addCommand('build');
13
+ parser.addOption(
14
+ 'port',
15
+ abbr: 'p',
16
+ help: 'Port for server',
17
+ defaultsTo: '8080',
18
+ );
19
+ parser.addOption(
20
+ 'proxy',
21
+ abbr: 'x',
22
+ help: 'Proxy API requests to this URL (e.g., http://localhost:3000)',
23
+ defaultsTo: '',
24
+ );
25
+ parser.addOption(
26
+ 'output',
27
+ abbr: 'o',
28
+ help: 'Output path for generated JSON spec (default: frontend/web/froggy_docs.json)',
29
+ defaultsTo: 'frontend/web/froggy_docs.json',
30
+ );
31
+ parser.addOption(
32
+ 'ignore',
33
+ help: 'Glob pattern for paths to exclude from watching (e.g., "**/*.g.dart")',
34
+ defaultsTo: '',
35
+ );
36
+
37
+ try {
38
+ final results = parser.parse(arguments);
39
+
40
+ final outputPath = results['output'] as String;
41
+ final ignorePattern = results['ignore'] as String;
42
+
43
+ if (results.command?.name == 'watch') {
44
+ print('🐸 FroggyDocs is watching your API project...');
45
+ final watcher = WatcherEngine(outputPath: outputPath, ignorePattern: ignorePattern);
46
+ watcher.startWatching(Directory.current.path);
47
+ } else if (results.command?.name == 'serve') {
48
+ final port = int.parse(results['port'] as String);
49
+ final proxyUrl = results['proxy'] as String;
50
+ print('🐸 Starting FroggyDocs server with live reload...');
51
+ if (proxyUrl.isNotEmpty) {
52
+ print('🔄 Proxy enabled: API requests will be forwarded to $proxyUrl');
53
+ }
54
+
55
+ await startServer(port: port, proxyUrl: proxyUrl);
56
+ final watcher = WatcherEngine(outputPath: outputPath, ignorePattern: ignorePattern);
57
+ watcher.startWatching(Directory.current.path);
58
+ } else if (results.command?.name == 'build') {
59
+ print('🐸 Building static FroggyDocs site...');
60
+ final parser = ParserEngine();
61
+ parser.setOutputPath(outputPath);
62
+ _buildStaticSite(parser, Directory.current.path);
63
+ print('✅ Build complete. Output: $outputPath');
64
+ } else {
65
+ _printHelp();
66
+ }
67
+ } catch (e) {
68
+ print('Error: ${e.toString()}');
69
+ exit(1);
70
+ }
71
+ }
72
+
73
+ void _buildStaticSite(ParserEngine parser, String directoryPath) {
74
+ final dir = Directory(directoryPath);
75
+ try {
76
+ dir.listSync(recursive: true).forEach((entity) {
77
+ if (entity is File) {
78
+ final normalizedPath = entity.path.replaceAll('\\', '/');
79
+ if (!_shouldIgnore(normalizedPath)) {
80
+ parser.parseFile(entity.path);
81
+ }
82
+ }
83
+ });
84
+ } catch (e) {
85
+ print('⚠️ Warning during build: $e');
86
+ }
87
+ }
88
+
89
+ bool _shouldIgnore(String path) {
90
+ return path.contains('/.dart_tool/') ||
91
+ path.contains('/frontend/') ||
92
+ path.contains('/node_modules/') ||
93
+ path.contains('/.git/') ||
94
+ path.endsWith('.json') ||
95
+ path.endsWith('.md');
96
+ }
97
+
98
+ void _printHelp() {
99
+ print('''
100
+ 🐸 FroggyDocs v1.0.0
101
+
102
+ Usage:
103
+ froggy_docs serve Start server with live documentation
104
+ froggy_docs watch Watch for changes and regenerate docs
105
+ froggy_docs build Generate static documentation without starting server
106
+
107
+ Options:
108
+ -p, --port <port> Port number (default: 8080)
109
+ -x, --proxy <url> Proxy API requests to this URL
110
+ -o, --output <path> Output path for generated JSON spec
111
+ (default: frontend/web/froggy_docs.json)
112
+ --ignore <glob> Glob pattern for paths to exclude from watching
113
+ (e.g., "**/*.g.dart")
114
+ -h, --help Show this help message
115
+
116
+ Examples:
117
+ froggy_docs serve --port 3000
118
+ froggy_docs serve --output docs/api.json
119
+ froggy_docs build --output docs/froggy_docs.json
120
+ froggy_docs watch --ignore "**/*.g.dart"
121
+ ''');
122
+ }
123
+ }
@@ -36,6 +36,14 @@ class ParserEngine {
36
36
  'options',
37
37
  };
38
38
 
39
+ String _outputPath = 'frontend/web/froggy_docs.json';
40
+
41
+ void setOutputPath(String path) {
42
+ _outputPath = path;
43
+ }
44
+
45
+ String get outputPath => _outputPath;
46
+
39
47
  final Map<String, dynamic> _baseSpec = {
40
48
  "openapi": "3.0.0",
41
49
  "info": {
@@ -104,6 +112,37 @@ class ParserEngine {
104
112
  }
105
113
  }
106
114
 
115
+ dynamic _parseDefaultValue(String value, String? type) {
116
+ switch (type?.toString().toLowerCase()) {
117
+ case 'number':
118
+ case 'integer':
119
+ case 'int':
120
+ return num.tryParse(value) ?? value;
121
+ case 'boolean':
122
+ return value.toLowerCase() == 'true';
123
+ default:
124
+ return value;
125
+ }
126
+ }
127
+
128
+ void _attachResponseExample(
129
+ String code,
130
+ dynamic jsonData,
131
+ Map<String, Map<String, dynamic>> responseSchemas,
132
+ ) {
133
+ final response = responseSchemas[code]!;
134
+ if (!response.containsKey('content')) {
135
+ response['content'] = <String, dynamic>{
136
+ "application/json": <String, dynamic>{
137
+ "schema": <String, dynamic>{"type": "object"},
138
+ },
139
+ };
140
+ }
141
+ final contentMap = response['content'] as Map;
142
+ final appJsonMap = contentMap['application/json'] as Map;
143
+ appJsonMap['example'] = jsonData;
144
+ }
145
+
107
146
  void parseFile(String filePath) {
108
147
  _errors.clear();
109
148
 
@@ -132,8 +171,15 @@ class ParserEngine {
132
171
  bool hasAuth = false;
133
172
  List<String> currentTags = [];
134
173
  List<Map<String, String>> requestBodyFields = [];
174
+ List<Map<String, String>> requestFileFields = [];
175
+ List<Map<String, dynamic>> queryParams = [];
176
+ List<Map<String, dynamic>> headerParams = [];
177
+ Map<String, Map<String, dynamic>> responseSchemas = {};
178
+ String? pendingResponseCode;
135
179
  bool isReadingBodyJson = false;
180
+ bool isReadingResponseJson = false;
136
181
  StringBuffer bodyJsonBuffer = StringBuffer();
182
+ StringBuffer responseJsonBuffer = StringBuffer();
137
183
  int lineNumber = 0;
138
184
 
139
185
  String inferType(dynamic value) {
@@ -146,6 +192,44 @@ class ParserEngine {
146
192
  }
147
193
 
148
194
  void flushEndpoint() {
195
+ // Defensively flush any pending JSON buffers (handles end-of-file cases).
196
+ if (isReadingBodyJson && bodyJsonBuffer.isNotEmpty) {
197
+ isReadingBodyJson = false;
198
+ try {
199
+ final jsonData = jsonDecode(bodyJsonBuffer.toString());
200
+ if (jsonData is Map) {
201
+ for (final entry in jsonData.entries) {
202
+ requestBodyFields.add({
203
+ 'name': entry.key.toString(),
204
+ 'type': inferType(entry.value),
205
+ 'desc': 'Inferred from JSON',
206
+ });
207
+ }
208
+ }
209
+ } catch (e) {
210
+ _errors.add('Invalid JSON in @body-json at line $lineNumber: $e');
211
+ }
212
+ bodyJsonBuffer.clear();
213
+ }
214
+
215
+ if (isReadingResponseJson &&
216
+ responseJsonBuffer.isNotEmpty &&
217
+ pendingResponseCode != null) {
218
+ isReadingResponseJson = false;
219
+ try {
220
+ final jsonData = jsonDecode(responseJsonBuffer.toString());
221
+ _attachResponseExample(
222
+ pendingResponseCode!,
223
+ jsonData,
224
+ responseSchemas,
225
+ );
226
+ } catch (e) {
227
+ _errors
228
+ .add('Invalid JSON in @response-json at line $lineNumber: $e');
229
+ }
230
+ responseJsonBuffer.clear();
231
+ }
232
+
149
233
  if (currentMethod != null) {
150
234
  if (currentPath == null) {
151
235
  _errors.add('Endpoint defined without path at line $lineNumber');
@@ -153,8 +237,15 @@ class ParserEngine {
153
237
  currentPath = null;
154
238
  description = '';
155
239
  hasAuth = false;
240
+ currentTags.clear();
156
241
  requestBodyFields.clear();
242
+ requestFileFields.clear();
243
+ queryParams.clear();
244
+ headerParams.clear();
245
+ responseSchemas.clear();
246
+ pendingResponseCode = null;
157
247
  bodyJsonBuffer.clear();
248
+ responseJsonBuffer.clear();
158
249
  return;
159
250
  }
160
251
 
@@ -166,8 +257,15 @@ class ParserEngine {
166
257
  currentPath = null;
167
258
  description = '';
168
259
  hasAuth = false;
260
+ currentTags.clear();
169
261
  requestBodyFields.clear();
262
+ requestFileFields.clear();
263
+ queryParams.clear();
264
+ headerParams.clear();
265
+ responseSchemas.clear();
266
+ pendingResponseCode = null;
170
267
  bodyJsonBuffer.clear();
268
+ responseJsonBuffer.clear();
171
269
  return;
172
270
  }
173
271
 
@@ -178,11 +276,16 @@ class ParserEngine {
178
276
  fileEndpoints[path] = {};
179
277
  }
180
278
 
279
+ final responses = <String, dynamic>{};
280
+ if (responseSchemas.isEmpty) {
281
+ responses['200'] = {"description": "Successful response"};
282
+ } else {
283
+ responses.addAll(responseSchemas);
284
+ }
285
+
181
286
  Map<String, dynamic> endpointData = {
182
287
  "summary": description.isNotEmpty ? description : "No description",
183
- "responses": {
184
- "200": {"description": "Successful response"},
185
- },
288
+ "responses": responses,
186
289
  };
187
290
 
188
291
  if (currentTags.isNotEmpty) {
@@ -195,25 +298,93 @@ class ParserEngine {
195
298
  ];
196
299
  }
197
300
 
198
- if (requestBodyFields.isNotEmpty) {
199
- Map<String, dynamic> properties = {};
301
+ final parameters = <Map<String, dynamic>>[];
302
+ for (var qp in queryParams) {
303
+ final param = <String, dynamic>{
304
+ "name": qp['name'],
305
+ "in": "query",
306
+ "description": qp['desc'] ?? '',
307
+ "schema": <String, dynamic>{
308
+ "type": (qp['type'] ?? 'string').toString().toLowerCase(),
309
+ },
310
+ };
311
+ if (qp['default'] != null && (qp['default'] as String).isNotEmpty) {
312
+ param['schema']['default'] = _parseDefaultValue(
313
+ qp['default'] as String,
314
+ qp['type'],
315
+ );
316
+ }
317
+ parameters.add(param);
318
+ }
319
+ for (var hp in headerParams) {
320
+ parameters.add({
321
+ "name": hp['name'],
322
+ "in": "header",
323
+ "description": hp['desc'] ?? '',
324
+ "schema": {
325
+ "type": (hp['type'] ?? 'string').toString().toLowerCase(),
326
+ },
327
+ });
328
+ }
329
+ final pathParamMatches = RegExp(r'\{(\w+)\}').allMatches(path);
330
+ for (final match in pathParamMatches) {
331
+ final paramName = match.group(1)!;
332
+ if (!parameters.any((p) => p['name'] == paramName && p['in'] == 'path')) {
333
+ parameters.insert(0, {
334
+ "name": paramName,
335
+ "in": "path",
336
+ "required": true,
337
+ "description": '',
338
+ "schema": {"type": "string"},
339
+ });
340
+ }
341
+ }
342
+
343
+ if (parameters.isNotEmpty) {
344
+ endpointData['parameters'] = parameters;
345
+ }
346
+
347
+ if (requestBodyFields.isNotEmpty || requestFileFields.isNotEmpty) {
348
+ final properties = <String, dynamic>{};
200
349
  for (var field in requestBodyFields) {
201
- final fieldName = field['name'];
202
- if (fieldName != null && fieldName.isNotEmpty) {
203
- properties[fieldName] = {
204
- "type": (field['type'] ?? 'string').toString().toLowerCase(),
205
- "description": field['desc'] ?? '',
206
- };
207
- }
350
+ final fieldName = field['name']!;
351
+ properties[fieldName] = {
352
+ "type": (field['type'] ?? 'string').toString().toLowerCase(),
353
+ "description": field['desc'] ?? '',
354
+ };
355
+ }
356
+ for (var field in requestFileFields) {
357
+ final fieldName = field['name']!;
358
+ properties[fieldName] = {
359
+ "type": "string",
360
+ "format": "binary",
361
+ "description": field['desc'] ?? '',
362
+ };
208
363
  }
209
364
 
210
- endpointData['requestBody'] = {
211
- "content": {
212
- "application/json": {
213
- "schema": {"type": "object", "properties": properties},
365
+ if (requestFileFields.isNotEmpty) {
366
+ endpointData['requestBody'] = {
367
+ "content": <String, dynamic>{
368
+ "multipart/form-data": <String, dynamic>{
369
+ "schema": <String, dynamic>{
370
+ "type": "object",
371
+ "properties": properties,
372
+ },
373
+ },
214
374
  },
215
- },
216
- };
375
+ };
376
+ } else {
377
+ endpointData['requestBody'] = {
378
+ "content": <String, dynamic>{
379
+ "application/json": <String, dynamic>{
380
+ "schema": <String, dynamic>{
381
+ "type": "object",
382
+ "properties": properties,
383
+ },
384
+ },
385
+ },
386
+ };
387
+ }
217
388
  }
218
389
 
219
390
  fileEndpoints[path][method] = endpointData;
@@ -225,7 +396,13 @@ class ParserEngine {
225
396
  hasAuth = false;
226
397
  currentTags.clear();
227
398
  requestBodyFields.clear();
399
+ requestFileFields.clear();
400
+ queryParams.clear();
401
+ headerParams.clear();
402
+ responseSchemas.clear();
403
+ pendingResponseCode = null;
228
404
  bodyJsonBuffer.clear();
405
+ responseJsonBuffer.clear();
229
406
  }
230
407
 
231
408
  for (final l in lines) {
@@ -259,6 +436,29 @@ class ParserEngine {
259
436
  }
260
437
  }
261
438
 
439
+ if (isReadingResponseJson) {
440
+ if (!_isCommentLine(trimmed, commentPrefix)) {
441
+ isReadingResponseJson = false;
442
+ if (responseJsonBuffer.isNotEmpty && pendingResponseCode != null) {
443
+ try {
444
+ final jsonData = jsonDecode(responseJsonBuffer.toString());
445
+ _attachResponseExample(
446
+ pendingResponseCode!,
447
+ jsonData,
448
+ responseSchemas,
449
+ );
450
+ } catch (e) {
451
+ _errors.add('Invalid response JSON at line $lineNumber: $e');
452
+ }
453
+ }
454
+ responseJsonBuffer.clear();
455
+ } else {
456
+ final commentText = _extractCommentText(trimmed, commentPrefix);
457
+ responseJsonBuffer.write(commentText);
458
+ continue;
459
+ }
460
+ }
461
+
262
462
  if (!_isCommentLine(trimmed, commentPrefix)) {
263
463
  if (trimmed.isNotEmpty) {
264
464
  flushEndpoint();
@@ -280,7 +480,10 @@ class ParserEngine {
280
480
  flushEndpoint();
281
481
  if (parts.length >= 3) {
282
482
  currentMethod = parts[1].toUpperCase();
283
- currentPath = parts[2];
483
+ currentPath = parts[2].replaceAllMapped(
484
+ RegExp(r':(\w+)'),
485
+ (m) => '{${m.group(1)}}',
486
+ );
284
487
  } else {
285
488
  _errors.add('@api requires method and path at line $lineNumber');
286
489
  }
@@ -309,7 +512,24 @@ class ParserEngine {
309
512
  'desc': fieldDesc,
310
513
  });
311
514
  } else {
312
- _errors.add('@body requires field name and type at line $lineNumber');
515
+ _errors.add(
516
+ '@body requires field name and type at line $lineNumber',
517
+ );
518
+ }
519
+ } else if (tag == '@file') {
520
+ if (parts.length >= 3) {
521
+ final fieldName = parts[1];
522
+ final fileType = parts[2];
523
+ final fieldDesc = parts.length > 3 ? parts.sublist(3).join(' ') : '';
524
+ requestFileFields.add({
525
+ 'name': fieldName,
526
+ 'type': fileType,
527
+ 'desc': fieldDesc,
528
+ });
529
+ } else {
530
+ _errors.add(
531
+ '@file requires field name and type at line $lineNumber',
532
+ );
313
533
  }
314
534
  } else if (tag == '@body-file') {
315
535
  if (parts.length >= 2) {
@@ -334,6 +554,92 @@ class ParserEngine {
334
554
  } else if (tag == '@body-json') {
335
555
  isReadingBodyJson = true;
336
556
  bodyJsonBuffer.clear();
557
+ } else if (tag == '@response') {
558
+ if (parts.length >= 3) {
559
+ pendingResponseCode = parts[1];
560
+ final responseType = parts[2];
561
+ final responseDesc =
562
+ parts.length > 3 ? parts.sublist(3).join(' ') : '';
563
+ final lowerType = responseType.toLowerCase();
564
+ final schemaType = (lowerType == 'array') ? 'array' : 'object';
565
+ responseSchemas[pendingResponseCode!] = {
566
+ "description": responseDesc,
567
+ "content": <String, dynamic>{
568
+ "application/json": <String, dynamic>{
569
+ "schema": <String, dynamic>{"type": schemaType},
570
+ },
571
+ },
572
+ };
573
+ } else {
574
+ _errors.add(
575
+ '@response requires status code and type at line $lineNumber',
576
+ );
577
+ }
578
+ } else if (tag == '@response-json') {
579
+ if (pendingResponseCode != null &&
580
+ responseSchemas.containsKey(pendingResponseCode)) {
581
+ final rest = commentText.replaceFirst('@response-json', '').trim();
582
+ if (rest.isNotEmpty) {
583
+ try {
584
+ final jsonData = jsonDecode(rest);
585
+ _attachResponseExample(
586
+ pendingResponseCode!,
587
+ jsonData,
588
+ responseSchemas,
589
+ );
590
+ } catch (_) {
591
+ isReadingResponseJson = true;
592
+ responseJsonBuffer.clear();
593
+ responseJsonBuffer.write(rest);
594
+ }
595
+ } else {
596
+ isReadingResponseJson = true;
597
+ responseJsonBuffer.clear();
598
+ }
599
+ }
600
+ } else if (tag == '@query') {
601
+ if (parts.length >= 3) {
602
+ final name = parts[1];
603
+ final type = parts[2];
604
+ String desc = '';
605
+ String? defaultValue;
606
+
607
+ final rest = parts.length > 3 ? parts.sublist(3).join(' ') : '';
608
+ final defaultMatch =
609
+ RegExp(r'\(default:\s*(.+?)\)').firstMatch(rest);
610
+ if (defaultMatch != null) {
611
+ defaultValue = defaultMatch.group(1);
612
+ desc = rest.replaceFirst(defaultMatch.group(0)!, '').trim();
613
+ } else {
614
+ desc = rest;
615
+ }
616
+
617
+ queryParams.add({
618
+ 'name': name,
619
+ 'type': type,
620
+ 'desc': desc,
621
+ 'default': defaultValue,
622
+ });
623
+ } else {
624
+ _errors.add(
625
+ '@query requires name and type at line $lineNumber',
626
+ );
627
+ }
628
+ } else if (tag == '@header') {
629
+ if (parts.length >= 3) {
630
+ final name = parts[1];
631
+ final type = parts[2];
632
+ final desc = parts.length > 3 ? parts.sublist(3).join(' ') : '';
633
+ headerParams.add({
634
+ 'name': name,
635
+ 'type': type,
636
+ 'desc': desc,
637
+ });
638
+ } else {
639
+ _errors.add(
640
+ '@header requires name and type at line $lineNumber',
641
+ );
642
+ }
337
643
  }
338
644
  }
339
645
 
@@ -341,7 +647,7 @@ class ParserEngine {
341
647
 
342
648
  if (_errors.isNotEmpty) {
343
649
  for (final error in _errors) {
344
- print('⚠️ $error');
650
+ print('⚠️ $error');
345
651
  }
346
652
  }
347
653
 
@@ -370,12 +676,11 @@ class ParserEngine {
370
676
 
371
677
  _baseSpec['paths'] = fullPaths;
372
678
 
373
- // Add tags if any
374
679
  if (_tags.isNotEmpty) {
375
680
  _baseSpec['tags'] = _tags.map((t) => {"name": t}).toList();
376
681
  }
377
682
 
378
- final swaggerFile = File('frontend/web/froggy_docs.json');
683
+ final swaggerFile = File(_outputPath);
379
684
  if (!swaggerFile.parent.existsSync()) {
380
685
  swaggerFile.parent.createSync(recursive: true);
381
686
  }
@@ -387,4 +692,45 @@ class ParserEngine {
387
692
  '🐸 FroggyDocs: Updated documentation. Total paths: ${fullPaths.length}',
388
693
  );
389
694
  }
695
+
696
+ Map<String, dynamic> generateSpec() {
697
+ final Map<String, dynamic> fullPaths = {};
698
+ for (final fileMap in _fileRegistry.values) {
699
+ for (final entry in fileMap.entries) {
700
+ final path = entry.key;
701
+ final methods = entry.value;
702
+ if (fullPaths[path] == null) {
703
+ fullPaths[path] = {};
704
+ }
705
+ (fullPaths[path] as Map).addAll(methods);
706
+ }
707
+ }
708
+ final spec = Map<String, dynamic>.from(_baseSpec);
709
+ spec['paths'] = fullPaths;
710
+ if (_tags.isNotEmpty) {
711
+ spec['tags'] = _tags.map((t) => {"name": t}).toList();
712
+ }
713
+ return spec;
714
+ }
715
+
716
+ void clearRegistry() {
717
+ _fileRegistry.clear();
718
+ _tags.clear();
719
+ _errors.clear();
720
+ }
721
+
722
+ int get pathCount {
723
+ final Map<String, dynamic> fullPaths = {};
724
+ for (final fileMap in _fileRegistry.values) {
725
+ for (final entry in fileMap.entries) {
726
+ final path = entry.key;
727
+ final methods = entry.value;
728
+ if (fullPaths[path] == null) {
729
+ fullPaths[path] = {};
730
+ }
731
+ (fullPaths[path] as Map).addAll(methods);
732
+ }
733
+ }
734
+ return fullPaths.length;
735
+ }
390
736
  }