UNPKG

mdlint

Version:

Lints markdown files to find JavaScript syntax errors

381 lines (326 loc) 10.2 kB
/* * mdlint * https://github.com/ChrisWren/mdlint * * Copyright (c) 2013 Chris Wren * Licensed under the MIT license. */ 'use strict'; var spawn = require('child_process').spawn; var fs = require('fs'); require('colors'); var program = require('commander'); var esprima = require('esprima'); var glob = require('glob'); var _ = require('lodash'); var request = require('request'); var headers = { // GitHub API requires the User-Agent Header to be set 'User-Agent': 'mdlint' }; // Location of token file to generate when user authenticates var tokenFile = __dirname + '/authtoken.txt'; // Keep track of the number of files to parse so that if all files pass a success message can be logged var numFilesToParse = 0; // Keep track of the number of failed files to change the file break color for readability var numFailedFiles = 0; module.exports = function () { // Use Auth Token if present if (fs.existsSync(tokenFile)) { headers.Authorization = 'token ' + fs.readFileSync(tokenFile, 'utf8'); } program .version(require('./package.json').version) .option('-v, --verbose', 'report linting for all files'); program .command('repo <repo>') .description('lints a README from a GitHub repo') .action(function (repo) { fetchRepoREADME(repo); }); program .command('user <username>') .description('lints all READMEs from a user\'s GitHub repos') .action(function (user) { fetchUserREADMEs(user); }); program .command('glob <glob>') .description('lints local markdown files that match a file glob') .action(function (fileGlob) { var files = glob.sync(fileGlob); numFilesToParse = files.length; files.forEach(function (file) { lintMarkdown(fs.readFileSync(file, 'utf8'), file); }); if (numFailedFiles > 0) { process.exit(1); } else if (!program.verbose) { console.log('All files passed linting.'); } }); program .command('query <query>') .option('--page', 'The page of results to return. Defaults to 0.') .description('lints READMEs from repos returned by a GitHub query.') .action(function (query) { request({ uri: 'https://api.github.com/legacy/repos/search/' + query + '?language=JavaScript' + '&start_page=' + program.page || '0', headers: headers }, function (error, response, body) { if (!error && response.statusCode === 200) { JSON.parse(body) .repositories .forEach(function (repo) { fetchRepoREADME(repo.owner + '/' + repo.name); }); } else { if (response.headers['x-ratelimit-remaining'] === '0') { getAuthToken(); } else { console.log(body); console.log(response.headers); console.log('Unable to reach the GitHub API :('.red); } return; } }); }); // Process implicit commands program .command('*') .action(function (command) { if (command.indexOf('*') !== -1 || command.indexOf('.') !== -1) { var files = glob.sync(command); numFilesToParse = files.length; files.forEach(function (file) { lintMarkdown(fs.readFileSync(file, 'utf8'), file); }); if (numFailedFiles > 0) { process.exit(1); } } else if (command.indexOf('/') !== -1) { fetchRepoREADME(command); } else { fetchUserREADMEs(command); } }); program .parse(process.argv); if (!program.args.length) { program.help(); } }; /** * Fetches READMEs from a user's GitHub repos * @param {String} GitHub username */ function fetchUserREADMEs (user) { request({ uri: 'https://api.github.com/users/' + user + '/repos', headers: headers }, function (error, response, body) { var responseBody = JSON.parse(body); if (responseBody.message) { console.log('Error: the following user was not found: '.red + user.blue); return; } JSON.parse(body) .forEach(function (repo) { fetchRepoREADME(repo.full_name); }); }); } /** * Fetches a README from GitHub * @param {String} repo URL of repo to fetch */ function fetchRepoREADME (repo) { request({ uri: 'https://api.github.com/repos/' + repo + '/readme', headers: _.extend(headers, { // Get raw README content 'Accept': 'application/vnd.github.VERSION.raw' }) }, function (error, response, body) { if (!error && response.statusCode === 200) { lintMarkdown(body, repo); } else { if (response.headers['x-ratelimit-remaining'] === '0') { getAuthToken(); } else { console.log('README for https://github.com/' + repo.blue + ' not found.'.red); return; } } }); } /** * [parseMarkdown description] * @param {String} markdownContent Content of markdown file * @return {Array} Array of objects with a language and code property */ function parseMarkdown (markdownContent) { var codeBlocks = markdownContent .split('```') .filter(function (codeBlock, index) { // Delete the text in between code sections if (index % 2 === 0 || // Remove non JavaScript and JSON code blocks (codeBlock.trim().toLowerCase().indexOf('js') !== 0 && codeBlock.trim().toLowerCase().indexOf('javascript') !== 0)) { return false; } return true; }) .map(function (codeBlock) { return { lang: codeBlock.split('\n')[0].trim().toLowerCase(), code: codeBlock.slice(codeBlock.indexOf('\n') + 1) }; }); return codeBlocks; } // Boolean to keep track if the file break has been logged when discovering multiple errors in a single file var didLogFileBreak; /** * Parses the JavaScript code blocks from the markdown file * @param {String} body Body of markdown file * @param {String} file Filename */ function lintMarkdown (body, file) { var codeBlocks = parseMarkdown(body); didLogFileBreak = false; var failedCodeBlocks = _.reject(_.compact(codeBlocks), function (codeBlock) { return validateCodeBlock(codeBlock, file); }); numFilesToParse--; if (failedCodeBlocks.length === 0) { if (program.verbose) { console.log('Markdown passed linting for '.green + file.blue.bold + '\n'); } else if (numFilesToParse === 0) { console.log('All markdown files passed linting'.green); } } else { if (numFailedFiles % 2 === 0) { console.log('Markdown failed linting for '.red + file.yellow); } else { console.log('Markdown failed linting for '.red + file.blue); } numFailedFiles++; console.log(''); } } /** * Logs a break between files for readability * @param {String} text Text to log */ function logFileBreak (text) { if (numFailedFiles % 2 === 0) { console.log(text.yellow.inverse); } else { console.log(text.blue.inverse); } } /** * Validates that code blocks are valid JavaScript * @param {Object} code A block of code from the markdown file containg the lang and code * @param {String} file Name of file currently being validated */ function validateCodeBlock (codeBlock, file) { var lang = codeBlock.lang; var code = codeBlock.code; if (lang === 'json') { try { JSON.parse(code); } catch (e) { console.log(e); console.log(code); return false; } return true; } else if (lang === 'js' || lang === 'javascript') { code = preprocessCode(code); try { esprima.parse(code, { tolerant: true }); } catch (e) { // Get indeces from lineNumber and column var line = e.lineNumber - 1; var column = e.column - 1; // Highlight error in code code = code.split('\n'); code[line] = code[line].slice(0, column).magenta + code[line][column].red + code[line].slice(column + 1).magenta; code = code.join('\n'); if (!didLogFileBreak) { logFileBreak(file); didLogFileBreak = true; } console.log(e); console.log(code); return false; } return true; } } /** * Retrieves an auth token so that the user can exceed the uauthenticated rate limit */ function getAuthToken () { console.log('You have hit the rate limit for unauthenticated requests, please log in to raise your rate limit:\n'.red); program.prompt('GitHub Username: ', function (user) { console.log('\nAfter entering your password, hit return' + ' twice.\n'.green); var authProcess = spawn('curl', [ '-u', user, '-d', '{"scopes":["repo"],"note":"mdlint"}', '-s', 'https://api.github.com/authorizations' ], { stdio: [process.stdin, 'pipe', process.stderr] }); authProcess.stdout.setEncoding('utf8'); authProcess.stdout.on('data', function (data) { var response = JSON.parse(data); if (response.message) { console.log(response.message.red + '\n'); process.exit(); } else { fs.writeFileSync(tokenFile, response.token); console.log('Authenticated :). Now try your lint again. \n'.green); process.exit(); } }); }); } /** * Preprocesses the code block and re-formats it to allow for partial code * @param {String} code A block of code from the markdown file * @return {String} Processed code transformed from partial code */ function preprocessCode (code) { // Remove starting comments while (code.indexOf('//') === 0) { code = code.slice(code.indexOf('\n')); } // Starts with an object literal if (code.indexOf('{') === 0) { code = 'var json = ' + code; // Starts with an object property } else if (code.indexOf(':') !== -1 && code.indexOf(':') < code.indexOf(' ')) { code = 'var json = {' + code + '}'; } // Starts with an anonymous function if (code.indexOf('function') === 0) { code = 'var func = ' + code; } // Contains ... return code.replace('...', ''); }