froggy-docs 1.1.0 → 1.1.3

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.
@@ -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,79 @@ 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
+ if (parameters.isNotEmpty) {
330
+ endpointData['parameters'] = parameters;
331
+ }
332
+
333
+ if (requestBodyFields.isNotEmpty || requestFileFields.isNotEmpty) {
334
+ final properties = <String, dynamic>{};
200
335
  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
- }
336
+ final fieldName = field['name']!;
337
+ properties[fieldName] = {
338
+ "type": (field['type'] ?? 'string').toString().toLowerCase(),
339
+ "description": field['desc'] ?? '',
340
+ };
341
+ }
342
+ for (var field in requestFileFields) {
343
+ final fieldName = field['name']!;
344
+ properties[fieldName] = {
345
+ "type": "string",
346
+ "format": "binary",
347
+ "description": field['desc'] ?? '',
348
+ };
208
349
  }
209
350
 
210
- endpointData['requestBody'] = {
211
- "content": {
212
- "application/json": {
213
- "schema": {"type": "object", "properties": properties},
351
+ if (requestFileFields.isNotEmpty) {
352
+ endpointData['requestBody'] = {
353
+ "content": <String, dynamic>{
354
+ "multipart/form-data": <String, dynamic>{
355
+ "schema": <String, dynamic>{
356
+ "type": "object",
357
+ "properties": properties,
358
+ },
359
+ },
214
360
  },
215
- },
216
- };
361
+ };
362
+ } else {
363
+ endpointData['requestBody'] = {
364
+ "content": <String, dynamic>{
365
+ "application/json": <String, dynamic>{
366
+ "schema": <String, dynamic>{
367
+ "type": "object",
368
+ "properties": properties,
369
+ },
370
+ },
371
+ },
372
+ };
373
+ }
217
374
  }
218
375
 
219
376
  fileEndpoints[path][method] = endpointData;
@@ -225,7 +382,13 @@ class ParserEngine {
225
382
  hasAuth = false;
226
383
  currentTags.clear();
227
384
  requestBodyFields.clear();
385
+ requestFileFields.clear();
386
+ queryParams.clear();
387
+ headerParams.clear();
388
+ responseSchemas.clear();
389
+ pendingResponseCode = null;
228
390
  bodyJsonBuffer.clear();
391
+ responseJsonBuffer.clear();
229
392
  }
230
393
 
231
394
  for (final l in lines) {
@@ -259,6 +422,29 @@ class ParserEngine {
259
422
  }
260
423
  }
261
424
 
425
+ if (isReadingResponseJson) {
426
+ if (!_isCommentLine(trimmed, commentPrefix)) {
427
+ isReadingResponseJson = false;
428
+ if (responseJsonBuffer.isNotEmpty && pendingResponseCode != null) {
429
+ try {
430
+ final jsonData = jsonDecode(responseJsonBuffer.toString());
431
+ _attachResponseExample(
432
+ pendingResponseCode!,
433
+ jsonData,
434
+ responseSchemas,
435
+ );
436
+ } catch (e) {
437
+ _errors.add('Invalid response JSON at line $lineNumber: $e');
438
+ }
439
+ }
440
+ responseJsonBuffer.clear();
441
+ } else {
442
+ final commentText = _extractCommentText(trimmed, commentPrefix);
443
+ responseJsonBuffer.write(commentText);
444
+ continue;
445
+ }
446
+ }
447
+
262
448
  if (!_isCommentLine(trimmed, commentPrefix)) {
263
449
  if (trimmed.isNotEmpty) {
264
450
  flushEndpoint();
@@ -309,7 +495,24 @@ class ParserEngine {
309
495
  'desc': fieldDesc,
310
496
  });
311
497
  } else {
312
- _errors.add('@body requires field name and type at line $lineNumber');
498
+ _errors.add(
499
+ '@body requires field name and type at line $lineNumber',
500
+ );
501
+ }
502
+ } else if (tag == '@file') {
503
+ if (parts.length >= 3) {
504
+ final fieldName = parts[1];
505
+ final fileType = parts[2];
506
+ final fieldDesc = parts.length > 3 ? parts.sublist(3).join(' ') : '';
507
+ requestFileFields.add({
508
+ 'name': fieldName,
509
+ 'type': fileType,
510
+ 'desc': fieldDesc,
511
+ });
512
+ } else {
513
+ _errors.add(
514
+ '@file requires field name and type at line $lineNumber',
515
+ );
313
516
  }
314
517
  } else if (tag == '@body-file') {
315
518
  if (parts.length >= 2) {
@@ -334,6 +537,92 @@ class ParserEngine {
334
537
  } else if (tag == '@body-json') {
335
538
  isReadingBodyJson = true;
336
539
  bodyJsonBuffer.clear();
540
+ } else if (tag == '@response') {
541
+ if (parts.length >= 3) {
542
+ pendingResponseCode = parts[1];
543
+ final responseType = parts[2];
544
+ final responseDesc =
545
+ parts.length > 3 ? parts.sublist(3).join(' ') : '';
546
+ final lowerType = responseType.toLowerCase();
547
+ final schemaType = (lowerType == 'array') ? 'array' : 'object';
548
+ responseSchemas[pendingResponseCode!] = {
549
+ "description": responseDesc,
550
+ "content": <String, dynamic>{
551
+ "application/json": <String, dynamic>{
552
+ "schema": <String, dynamic>{"type": schemaType},
553
+ },
554
+ },
555
+ };
556
+ } else {
557
+ _errors.add(
558
+ '@response requires status code and type at line $lineNumber',
559
+ );
560
+ }
561
+ } else if (tag == '@response-json') {
562
+ if (pendingResponseCode != null &&
563
+ responseSchemas.containsKey(pendingResponseCode)) {
564
+ final rest = commentText.replaceFirst('@response-json', '').trim();
565
+ if (rest.isNotEmpty) {
566
+ try {
567
+ final jsonData = jsonDecode(rest);
568
+ _attachResponseExample(
569
+ pendingResponseCode!,
570
+ jsonData,
571
+ responseSchemas,
572
+ );
573
+ } catch (_) {
574
+ isReadingResponseJson = true;
575
+ responseJsonBuffer.clear();
576
+ responseJsonBuffer.write(rest);
577
+ }
578
+ } else {
579
+ isReadingResponseJson = true;
580
+ responseJsonBuffer.clear();
581
+ }
582
+ }
583
+ } else if (tag == '@query') {
584
+ if (parts.length >= 3) {
585
+ final name = parts[1];
586
+ final type = parts[2];
587
+ String desc = '';
588
+ String? defaultValue;
589
+
590
+ final rest = parts.length > 3 ? parts.sublist(3).join(' ') : '';
591
+ final defaultMatch =
592
+ RegExp(r'\(default:\s*(.+?)\)').firstMatch(rest);
593
+ if (defaultMatch != null) {
594
+ defaultValue = defaultMatch.group(1);
595
+ desc = rest.replaceFirst(defaultMatch.group(0)!, '').trim();
596
+ } else {
597
+ desc = rest;
598
+ }
599
+
600
+ queryParams.add({
601
+ 'name': name,
602
+ 'type': type,
603
+ 'desc': desc,
604
+ 'default': defaultValue,
605
+ });
606
+ } else {
607
+ _errors.add(
608
+ '@query requires name and type at line $lineNumber',
609
+ );
610
+ }
611
+ } else if (tag == '@header') {
612
+ if (parts.length >= 3) {
613
+ final name = parts[1];
614
+ final type = parts[2];
615
+ final desc = parts.length > 3 ? parts.sublist(3).join(' ') : '';
616
+ headerParams.add({
617
+ 'name': name,
618
+ 'type': type,
619
+ 'desc': desc,
620
+ });
621
+ } else {
622
+ _errors.add(
623
+ '@header requires name and type at line $lineNumber',
624
+ );
625
+ }
337
626
  }
338
627
  }
339
628
 
@@ -341,7 +630,7 @@ class ParserEngine {
341
630
 
342
631
  if (_errors.isNotEmpty) {
343
632
  for (final error in _errors) {
344
- print('⚠️ $error');
633
+ print('⚠️ $error');
345
634
  }
346
635
  }
347
636
 
@@ -370,12 +659,11 @@ class ParserEngine {
370
659
 
371
660
  _baseSpec['paths'] = fullPaths;
372
661
 
373
- // Add tags if any
374
662
  if (_tags.isNotEmpty) {
375
663
  _baseSpec['tags'] = _tags.map((t) => {"name": t}).toList();
376
664
  }
377
665
 
378
- final swaggerFile = File('frontend/web/froggy_docs.json');
666
+ final swaggerFile = File(_outputPath);
379
667
  if (!swaggerFile.parent.existsSync()) {
380
668
  swaggerFile.parent.createSync(recursive: true);
381
669
  }
@@ -387,4 +675,45 @@ class ParserEngine {
387
675
  '🐸 FroggyDocs: Updated documentation. Total paths: ${fullPaths.length}',
388
676
  );
389
677
  }
678
+
679
+ Map<String, dynamic> generateSpec() {
680
+ final Map<String, dynamic> fullPaths = {};
681
+ for (final fileMap in _fileRegistry.values) {
682
+ for (final entry in fileMap.entries) {
683
+ final path = entry.key;
684
+ final methods = entry.value;
685
+ if (fullPaths[path] == null) {
686
+ fullPaths[path] = {};
687
+ }
688
+ (fullPaths[path] as Map).addAll(methods);
689
+ }
690
+ }
691
+ final spec = Map<String, dynamic>.from(_baseSpec);
692
+ spec['paths'] = fullPaths;
693
+ if (_tags.isNotEmpty) {
694
+ spec['tags'] = _tags.map((t) => {"name": t}).toList();
695
+ }
696
+ return spec;
697
+ }
698
+
699
+ void clearRegistry() {
700
+ _fileRegistry.clear();
701
+ _tags.clear();
702
+ _errors.clear();
703
+ }
704
+
705
+ int get pathCount {
706
+ final Map<String, dynamic> fullPaths = {};
707
+ for (final fileMap in _fileRegistry.values) {
708
+ for (final entry in fileMap.entries) {
709
+ final path = entry.key;
710
+ final methods = entry.value;
711
+ if (fullPaths[path] == null) {
712
+ fullPaths[path] = {};
713
+ }
714
+ (fullPaths[path] as Map).addAll(methods);
715
+ }
716
+ }
717
+ return fullPaths.length;
718
+ }
390
719
  }
@@ -1,10 +1,24 @@
1
1
  import 'dart:io';
2
+ import 'dart:async';
2
3
  import 'package:watcher/watcher.dart';
3
4
  import 'package:path/path.dart' as p;
4
5
  import 'parser_engine.dart';
5
6
 
6
7
  class WatcherEngine {
7
- final ParserEngine _parser = ParserEngine();
8
+ final ParserEngine _parser;
9
+ Timer? _debounceTimer;
10
+ final Duration _debounceDuration;
11
+ final String? _ignorePattern;
12
+
13
+ WatcherEngine({
14
+ String outputPath = 'frontend/web/froggy_docs.json',
15
+ String ignorePattern = '',
16
+ Duration debounceDuration = const Duration(milliseconds: 300),
17
+ }) : _parser = ParserEngine(),
18
+ _debounceDuration = debounceDuration,
19
+ _ignorePattern = ignorePattern.isEmpty ? null : ignorePattern {
20
+ _parser.setOutputPath(outputPath);
21
+ }
8
22
 
9
23
  void startWatching(String directoryPath) {
10
24
  print('🚀 Initializing FroggyDocs scan...');
@@ -28,31 +42,49 @@ class WatcherEngine {
28
42
  }
29
43
  });
30
44
  } catch (e) {
31
- print('⚠️ Warning during initial scan: $e');
45
+ print('⚠️ Warning during initial scan: $e');
32
46
  }
33
47
  print('✅ Initial scan complete.\n');
34
48
  }
35
49
 
36
50
  bool _shouldIgnore(String path) {
37
51
  final normalizedPath = path.replaceAll('\\', '/');
38
- return normalizedPath.contains('/.dart_tool/') ||
52
+ if (normalizedPath.contains('/.dart_tool/') ||
39
53
  normalizedPath.contains('/frontend/') ||
40
54
  normalizedPath.contains('/node_modules/') ||
41
55
  normalizedPath.contains('/.git/') ||
42
56
  normalizedPath.endsWith('.json') ||
43
- normalizedPath.endsWith('.md');
57
+ normalizedPath.endsWith('.md')) {
58
+ return true;
59
+ }
60
+
61
+ if (_ignorePattern != null) {
62
+ final regexPattern = _ignorePattern
63
+ .replaceAll('.', r'\.')
64
+ .replaceAll('*', '.*')
65
+ .replaceAll('?', '.');
66
+ if (RegExp(regexPattern).hasMatch(normalizedPath)) {
67
+ return true;
68
+ }
69
+ }
70
+
71
+ return false;
44
72
  }
45
73
 
46
74
  void _handleFileChange(WatchEvent event) {
47
- switch (event.type) {
48
- case ChangeType.ADD:
49
- case ChangeType.MODIFY:
50
- _parser.parseFile(event.path);
51
- break;
52
- case ChangeType.REMOVE:
53
- print('🗑️ Removing documentation for: ${p.basename(event.path)}');
54
- _parser.removeFile(event.path);
55
- break;
56
- }
75
+ _debounceTimer?.cancel();
76
+
77
+ _debounceTimer = Timer(_debounceDuration, () {
78
+ switch (event.type) {
79
+ case ChangeType.ADD:
80
+ case ChangeType.MODIFY:
81
+ _parser.parseFile(event.path);
82
+ break;
83
+ case ChangeType.REMOVE:
84
+ print('🗑️ Removing documentation for: ${p.basename(event.path)}');
85
+ _parser.removeFile(event.path);
86
+ break;
87
+ }
88
+ });
57
89
  }
58
90
  }