Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Markdown Validator | |
| on: | |
| pull_request: | |
| types: [opened, synchronize, reopened] | |
| paths: | |
| - '**.md' | |
| workflow_dispatch: | |
| jobs: | |
| validate-markdown: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v3 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v3 | |
| with: | |
| node-version: '18' | |
| - name: Install dependencies | |
| run: | | |
| npm install axios | |
| - name: Get changed files | |
| id: changed-files | |
| uses: tj-actions/changed-files@v41 | |
| with: | |
| files: | | |
| **/*.md | |
| files_ignore: | | |
| **/node_modules/** | |
| - name: Create validation script | |
| run: | | |
| cat > validate-markdown.js << 'EOF' | |
| #!/usr/bin/env node | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| // Get all markdown files from the repository | |
| const getAllMarkdownFiles = (dir, fileList = [], excludeDirs = ['node_modules', '.git']) => { | |
| const files = fs.readdirSync(dir); | |
| files.forEach(file => { | |
| const filePath = path.join(dir, file); | |
| // Skip excluded directories | |
| if (fs.statSync(filePath).isDirectory()) { | |
| if (!excludeDirs.includes(file)) { | |
| getAllMarkdownFiles(filePath, fileList, excludeDirs); | |
| } | |
| } else if (file.endsWith('.md')) { | |
| fileList.push(filePath); | |
| } | |
| }); | |
| return fileList; | |
| }; | |
| // Validate headers structure (should follow proper hierarchy) | |
| const validateHeaderStructure = (filePath) => { | |
| const content = fs.readFileSync(filePath, 'utf8'); | |
| const lines = content.split('\n'); | |
| const headerLevels = []; | |
| const yamlSection = content.startsWith('---'); | |
| let inYamlSection = yamlSection; | |
| let valid = true; | |
| for (let i = 0; i < lines.length; i++) { | |
| const line = lines[i]; | |
| // Skip YAML front matter | |
| if (line === '---') { | |
| inYamlSection = !inYamlSection; | |
| continue; | |
| } | |
| if (inYamlSection) { | |
| continue; | |
| } | |
| // Check if line is a header | |
| if (line.startsWith('#')) { | |
| const level = line.match(/^#+/)[0].length; | |
| // First header should be level 1 or 2 | |
| if (headerLevels.length === 0 && level > 2) { | |
| console.error(`❌ ${filePath}:${i+1}: First header should be level 1 or 2, found level ${level}`); | |
| valid = false; | |
| } | |
| // Headers should not skip levels (e.g., H2 -> H4) | |
| if (headerLevels.length > 0 && level > headerLevels[headerLevels.length - 1] + 1) { | |
| console.error(`❌ ${filePath}:${i+1}: Header level skipped from ${headerLevels[headerLevels.length - 1]} to ${level}`); | |
| valid = false; | |
| } | |
| // Check header format: exactly one space after # | |
| const headerFormat = line.match(/^#+\s/); | |
| if (!headerFormat || line.match(/^#+\s\s+/)) { | |
| console.error(`❌ ${filePath}:${i+1}: Header format error: should have exactly one space after #: "${line}"`); | |
| valid = false; | |
| } | |
| headerLevels.push(level); | |
| } | |
| } | |
| return valid; | |
| }; | |
| // Check for trailing whitespace and extra spaces | |
| const validateWhitespace = (filePath) => { | |
| const content = fs.readFileSync(filePath, 'utf8'); | |
| const lines = content.split('\n'); | |
| let valid = true; | |
| let inCodeBlock = false; | |
| for (let i = 0; i < lines.length; i++) { | |
| const line = lines[i]; | |
| // Check for trailing whitespace | |
| if (line.match(/\s+$/)) { | |
| console.error(`❌ ${filePath}:${i+1}: Line has trailing whitespace: "${line}"`); | |
| valid = false; | |
| } | |
| // Check for multiple spaces in text (excluding code blocks, tables, and front matter) | |
| // Track if we're inside a code block | |
| if (line.trim().startsWith('```')) { | |
| inCodeBlock = !inCodeBlock; | |
| } | |
| // Only check for multiple spaces if we're not in a code block, table, or special formatting | |
| if (!inCodeBlock && | |
| !line.startsWith(' ') && | |
| !line.startsWith('```') && | |
| !line.match(/^#+\s/) && | |
| !line.match(/^\s*-\s/) && | |
| !line.match(/^\|/) && | |
| !line.includes('[secondary_label') && | |
| !line.includes(' Running') && // Common in command outputs | |
| !line.match(/\S\s{3,}\S/) // Allow up to 2 spaces between words | |
| ) { | |
| // Check for multiple spaces between words (3 or more) | |
| if (line.match(/\S\s{3,}\S/)) { | |
| console.error(`❌ ${filePath}:${i+1}: Line has excessive spaces (3+): "${line}"`); | |
| valid = false; | |
| } | |
| } | |
| } | |
| // Check if file ends with a single newline | |
| if (!content.endsWith('\n') || content.endsWith('\n\n')) { | |
| console.error(`❌ ${filePath}: File should end with a single newline character`); | |
| valid = false; | |
| } | |
| return valid; | |
| }; | |
| // Main validation function | |
| const validateMarkdownFiles = () => { | |
| const markdownFiles = getAllMarkdownFiles('.'); | |
| let allValid = true; | |
| console.log(`Found ${markdownFiles.length} markdown files to validate`); | |
| for (const file of markdownFiles) { | |
| console.log(`\nValidating ${file}...`); | |
| const headerStructureValid = validateHeaderStructure(file); | |
| const whitespaceValid = validateWhitespace(file); | |
| const fileValid = headerStructureValid && whitespaceValid; | |
| if (fileValid) { | |
| console.log(`✅ ${file}: All checks passed`); | |
| } else { | |
| allValid = false; | |
| } | |
| } | |
| return allValid; | |
| }; | |
| // Run validation | |
| const allValid = validateMarkdownFiles(); | |
| if (!allValid) { | |
| console.error('\n❌ Validation failed'); | |
| process.exit(1); | |
| } else { | |
| console.log('\n✅ All markdown files validated successfully'); | |
| } | |
| EOF | |
| chmod +x validate-markdown.js | |
| - name: Run custom validator | |
| run: | | |
| if [ "${{ steps.changed-files.outputs.all_changed_files }}" == "" ]; then | |
| echo "No markdown files changed in this PR. Skipping validation." | |
| exit 0 | |
| fi | |
| for file in ${{ steps.changed-files.outputs.all_changed_files }}; do | |
| echo "Validating $file..." | |
| node validate-markdown.js "$file" | |
| done | |
| - name: Create grammar checker script | |
| run: | | |
| cat > grammar-checker.js << 'EOF' | |
| #!/usr/bin/env node | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const axios = require('axios'); | |
| // DigitalOcean GenAI API endpoint | |
| const API_ENDPOINT = 'https://api.digitalocean.com/v2/ai'; | |
| // Your API token (to be provided as an environment variable) | |
| const API_TOKEN = process.env.DO_API_TOKEN; | |
| if (!API_TOKEN) { | |
| console.error('❌ Error: DigitalOcean API token not found. Please set the DO_API_TOKEN environment variable.'); | |
| process.exit(1); | |
| } | |
| // Extract plain text content from markdown | |
| const extractTextFromMarkdown = (content) => { | |
| // Remove YAML front matter | |
| let text = content; | |
| if (content.startsWith('---')) { | |
| const endOfFrontMatter = content.indexOf('---', 3); | |
| if (endOfFrontMatter !== -1) { | |
| text = content.slice(endOfFrontMatter + 3); | |
| } | |
| } | |
| // Remove code blocks | |
| text = text.replace(/```[\s\S]*?```/g, ''); | |
| // Remove HTML tags | |
| text = text.replace(/<[^>]*>/g, ''); | |
| // Remove markdown links but keep the text | |
| text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); | |
| // Remove images | |
| text = text.replace(/!\[[^\]]*\]\([^)]+\)/g, ''); | |
| return text; | |
| }; | |
| // Function to check grammar using DigitalOcean's GenAI API | |
| const checkGrammar = async (text) => { | |
| try { | |
| const response = await axios.post(API_ENDPOINT, { | |
| model: 'claude-3.5-sonnet', | |
| prompt: `Please review the following text for grammatical errors, typos, incorrect sentence structures, passive voice, and unnecessary jargon. For each issue, identify the specific problem, explain why it's an issue, and suggest a correction. Format your response as a JSON array with objects containing: "issue_type", "text_with_issue", "explanation", and "suggestion". | |
| Text to review: | |
| ${text} | |
| Only identify actual issues. If there are no grammatical problems, return an empty array. | |
| Please format your response as valid JSON without any additional text.`, | |
| max_tokens: 1024 | |
| }, { | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${API_TOKEN}` | |
| } | |
| }); | |
| // Parse the AI response to get the JSON data | |
| const aiResponse = response.data.choices[0].message.content; | |
| try { | |
| // Extract JSON from the response (in case there's additional text) | |
| const jsonMatch = aiResponse.match(/\[[\s\S]*\]/); | |
| return jsonMatch ? JSON.parse(jsonMatch[0]) : []; | |
| } catch (e) { | |
| console.error('❌ Error parsing AI response:', e); | |
| console.log('AI response:', aiResponse); | |
| return []; | |
| } | |
| } catch (error) { | |
| console.error('❌ Error checking grammar:', error.message); | |
| if (error.response) { | |
| console.error('Response status:', error.response.status); | |
| console.error('Response data:', error.response.data); | |
| } | |
| return []; | |
| } | |
| }; | |
| // Process a single markdown file | |
| const processFile = async (filePath) => { | |
| try { | |
| console.log(`\nChecking grammar in ${filePath}...`); | |
| const content = fs.readFileSync(filePath, 'utf8'); | |
| const textToCheck = extractTextFromMarkdown(content); | |
| // Skip empty files or files with very little text content | |
| if (textToCheck.trim().length < 50) { | |
| console.log(`⚠️ Skipping ${filePath}: Not enough text content to check`); | |
| return true; | |
| } | |
| const issues = await checkGrammar(textToCheck); | |
| if (issues.length === 0) { | |
| console.log(`✅ ${filePath}: No grammar issues found`); | |
| return true; | |
| } else { | |
| console.log(`⚠️ ${filePath}: Found ${issues.length} grammar issues:`); | |
| issues.forEach((issue, index) => { | |
| console.log(` ${index + 1}. ${issue.issue_type}: "${issue.text_with_issue}"`); | |
| console.log(` Explanation: ${issue.explanation}`); | |
| console.log(` Suggestion: ${issue.suggestion}`); | |
| console.log(); | |
| }); | |
| return false; | |
| } | |
| } catch (error) { | |
| console.error(`❌ Error processing ${filePath}:`, error.message); | |
| return false; | |
| } | |
| }; | |
| // Main function to process all markdown files | |
| const checkAllFiles = async () => { | |
| // Get all markdown files | |
| const getAllMarkdownFiles = (dir, fileList = []) => { | |
| const files = fs.readdirSync(dir); | |
| files.forEach(file => { | |
| const filePath = path.join(dir, file); | |
| if (fs.statSync(filePath).isDirectory()) { | |
| getAllMarkdownFiles(filePath, fileList); | |
| } else if (file.endsWith('.md')) { | |
| fileList.push(filePath); | |
| } | |
| }); | |
| return fileList; | |
| }; | |
| const markdownFiles = getAllMarkdownFiles('.'); | |
| console.log(`Found ${markdownFiles.length} markdown files to check for grammar`); | |
| let allValid = true; | |
| // Process each file | |
| for (const file of markdownFiles) { | |
| const fileValid = await processFile(file); | |
| if (!fileValid) { | |
| allValid = false; | |
| } | |
| } | |
| return allValid; | |
| }; | |
| // Run the grammar checker | |
| checkAllFiles().then(allValid => { | |
| if (!allValid) { | |
| console.error('\n❌ Grammar check failed: Issues were found'); | |
| process.exit(1); | |
| } else { | |
| console.log('\n✅ Grammar check passed: No issues were found'); | |
| process.exit(0); | |
| } | |
| }).catch(error => { | |
| console.error('❌ Error running grammar check:', error.message); | |
| process.exit(1); | |
| }); | |
| EOF | |
| chmod +x grammar-checker.js | |
| - name: Run grammar checker | |
| if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository | |
| run: | | |
| if [ "${{ steps.changed-files.outputs.all_changed_files }}" == "" ]; then | |
| echo "No markdown files changed in this PR. Skipping grammar check." | |
| exit 0 | |
| fi | |
| for file in ${{ steps.changed-files.outputs.all_changed_files }}; do | |
| echo "Checking grammar in $file..." | |
| node grammar-checker.js "$file" | |
| done | |
| env: | |
| DO_API_TOKEN: ${{ secrets.DO_API_TOKEN }} |