Skip to content

ff

ff #15

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 }}