import 'dart:convert'; import '../models/deck.dart'; import '../models/markdown_validation.dart'; import 'markdown_service.dart'; /// Validates deck markdown against what [MarkdownService] can parse reliably. class MarkdownValidator { static const _knownClassTokens = { 'title', 'section', 'two-bullets', 'split', 'quote', 'video', 'table', 'code', 'chart', 'logo-safe', 'no-logo', 'no-footer', }; static const _validListStyles = {'bullets', 'numbered', 'checklist'}; MarkdownValidationResult validate(String markdown) { final issues = []; if (markdown.trim().isEmpty) { issues.add( const MarkdownValidationIssue( line: 1, severity: MarkdownValidationSeverity.warning, message: 'De presentatie is leeg.', ), ); return MarkdownValidationResult(issues); } final lines = markdown.split('\n'); _validateFrontMatter(lines, issues); _validateHtmlComments(lines, issues); _validateFenceBalance(lines, issues); final body = _stripFrontMatter(markdown); final bodyStartLine = markdown.length - body.length > 0 ? markdown.substring(0, markdown.length - body.length).split('\n').length : 1; final blocks = body.split(RegExp(r'\n---\n')); if (blocks.every((block) => block.trim().isEmpty)) { issues.add( MarkdownValidationIssue( line: bodyStartLine, severity: MarkdownValidationSeverity.error, message: 'Geen slides gevonden.', ), ); } var blockStartLine = bodyStartLine; for (var i = 0; i < blocks.length; i++) { final block = blocks[i].trim(); if (block.isEmpty) { blockStartLine += 1; continue; } _validateSlideBlock( block: block, slideNumber: i + 1, startLine: blockStartLine, issues: issues, ); blockStartLine += block.split('\n').length + 1; } if (MarkdownService().parseDeck(markdown) == null) { issues.add( const MarkdownValidationIssue( line: 1, severity: MarkdownValidationSeverity.error, message: 'De markdown kon niet worden ingelezen. Controleer de structuur.', ), ); } issues.sort((a, b) => a.line.compareTo(b.line)); return MarkdownValidationResult(issues); } void _validateFrontMatter( List lines, List issues, ) { if (lines.isEmpty || lines.first != '---') return; final closingIndex = _indexOfFrontMatterClose(lines); if (closingIndex == -1) { issues.add( const MarkdownValidationIssue( line: 1, severity: MarkdownValidationSeverity.error, message: 'Front matter is niet afgesloten. Sluit af met een regel `---`.', ), ); return; } for (var i = 1; i < closingIndex; i++) { final line = lines[i].trim(); if (line.isEmpty || line.startsWith('#')) continue; if (!line.contains(':')) { issues.add( MarkdownValidationIssue( line: i + 1, severity: MarkdownValidationSeverity.warning, message: 'Front matter-regel heeft geen sleutel:waarde-vorm.', ), ); continue; } final key = line.substring(0, line.indexOf(':')).trim(); if (key == 'tlp') { final value = line.substring(line.indexOf(':') + 1).trim(); if (!_isValidTlpKey(value)) { issues.add( MarkdownValidationIssue( line: i + 1, severity: MarkdownValidationSeverity.error, message: 'Onbekend TLP-niveau "$value". Gebruik clear, green, amber, amber+strict of red.', ), ); } } } } int _indexOfFrontMatterClose(List lines) { for (var i = 1; i < lines.length; i++) { if (lines[i] == '---') return i; } return -1; } String _stripFrontMatter(String markdown) { if (!markdown.startsWith('---\n')) return markdown; final end = markdown.indexOf('\n---\n', 4); if (end == -1) return markdown; return markdown.substring(end + 5).trim(); } void _validateHtmlComments( List lines, List issues, ) { for (var i = 0; i < lines.length; i++) { final line = lines[i]; final openCount = ''.allMatches(line).length; if (openCount > closeCount) { issues.add( MarkdownValidationIssue( line: i + 1, severity: MarkdownValidationSeverity.error, message: 'HTML-commentaar is niet afgesloten met `-->`.', ), ); } final classLike = RegExp(r'').firstMatch(line); if (classLike != null && !line.contains('_class:') && !line.contains('ocideck_') && !line.contains('_style:') && classLike.group(1)?.trim() != 'skip' && !RegExp(r'^tlp:\s*\S').hasMatch(classLike.group(1)!.trim()) && !RegExp(r'^advance:\s*[\d.]+').hasMatch(classLike.group(1)!.trim())) { issues.add( MarkdownValidationIssue( line: i + 1, severity: MarkdownValidationSeverity.warning, message: 'Commentaar mist `_class:` of een bekende ocideck-sleutel.', ), ); } } } void _validateFenceBalance( List lines, List issues, ) { var fenceCount = 0; int? firstFenceLine; for (var i = 0; i < lines.length; i++) { if (RegExp(r'^\s*```').hasMatch(lines[i])) { fenceCount++; firstFenceLine ??= i + 1; } } if (fenceCount.isOdd && firstFenceLine != null) { issues.add( MarkdownValidationIssue( line: firstFenceLine, severity: MarkdownValidationSeverity.error, message: 'Codeblok is niet afgesloten met ```.', ), ); } } void _validateSlideBlock({ required String block, required int slideNumber, required int startLine, required List issues, }) { final blockLines = block.split('\n'); int lineNo(int index) => startLine + index; final classMatch = RegExp( r'', ).firstMatch(block); if (classMatch == null && RegExp(r'`.', ), ); } } final cssClass = classMatch?.group(1)?.trim() ?? ''; final classTokens = cssClass .split(RegExp(r'\s+')) .where((token) => token.isNotEmpty) .toList(); for (final token in classTokens) { if (!_knownClassTokens.contains(token)) { final classLine = blockLines.indexWhere( (line) => line.contains('', '').trim(); if (!_isValidTlpKey(value)) { issues.add( MarkdownValidationIssue( line: lineNo(i), severity: MarkdownValidationSeverity.error, message: 'Slide $slideNumber: onbekend TLP-niveau "$value".', ), ); } } if (trimmed.startsWith('', '') .trim(); if (double.tryParse(value) == null) { issues.add( MarkdownValidationIssue( line: lineNo(i), severity: MarkdownValidationSeverity.error, message: 'Slide $slideNumber: advance-waarde "$value" is geen getal.', ), ); } } if (trimmed.startsWith('', '') .trim(); if (!_validListStyles.contains(value)) { issues.add( MarkdownValidationIssue( line: lineNo(i), severity: MarkdownValidationSeverity.error, message: 'Slide $slideNumber: onbekende lijststijl "$value". Gebruik bullets, numbered of checklist.', ), ); } } for (final prefix in const [ 'ocideck_two_bullets_left:', 'ocideck_two_bullets_right:', 'ocideck_two_bullets_left_title:', 'ocideck_two_bullets_right_title:', ]) { if (trimmed.startsWith('', '') .trim(); if (!_isValidEncodedPayload(prefix, encoded)) { issues.add( MarkdownValidationIssue( line: lineNo(i), severity: MarkdownValidationSeverity.error, message: 'Slide $slideNumber: `$prefix`-commentaar bevat ongeldige base64/JSON.', ), ); } } } if (RegExp(r'!\[[^\]]*\]\([^)]*$').hasMatch(trimmed)) { issues.add( MarkdownValidationIssue( line: lineNo(i), severity: MarkdownValidationSeverity.error, message: 'Slide $slideNumber: afbeeldings-markdown is niet afgesloten met `)`.', ), ); } if (trimmed.startsWith('')) { issues.add( MarkdownValidationIssue( line: lineNo(i), severity: MarkdownValidationSeverity.error, message: 'Slide $slideNumber: `