├── .jscs.json ├── .githooks └── pre-commit │ ├── test │ └── lint ├── .bithoundrc ├── .travis.yml ├── .eslintrc ├── test ├── .eslintrc ├── bootstrap.js ├── providers │ ├── feed.data.js │ ├── feed.test.js │ ├── feed-validator.test.js │ └── feed-validator.data.js ├── actions │ ├── validate-by-plugins.test.js │ ├── suppress.test.js │ └── suppress.data.js ├── reporter.test.js └── reporter.data.js ├── reporter ├── type-json.js ├── index.js └── type-text.js ├── .gitignore ├── actions ├── validate-by-w3c.js ├── suppress.js ├── validate-by-plugins.js └── cli │ └── get-options.js ├── providers ├── feed.js └── feed-validator.js ├── LICENSE ├── examples └── config.yandex.js ├── package.json ├── cli └── run.js └── README.md /.jscs.json: -------------------------------------------------------------------------------- 1 | {"preset": "yandex"} 2 | -------------------------------------------------------------------------------- /.githooks/pre-commit/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | npm run test 3 | -------------------------------------------------------------------------------- /.bithoundrc: -------------------------------------------------------------------------------- 1 | { 2 | "test": ["test/**"], 3 | "critics": { 4 | "lint": {"engine": "standard"} 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '6' 4 | - '8' 5 | - '9' 6 | install: npm install --save-dev 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "env": {"node": true}, 4 | "rules": { 5 | "no-console": 0 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": {"mocha": true}, 3 | "globals": { 4 | "assert": false, 5 | "sinon": false, 6 | "chai": false 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test/bootstrap.js: -------------------------------------------------------------------------------- 1 | global.sinon = require('sinon'); 2 | global.chai = require('chai'); 3 | global.assert = global.chai.assert; 4 | 5 | chai.use(require('chai-as-promised')); 6 | sinon.assert.expose(chai.assert, {prefix: ''}); 7 | -------------------------------------------------------------------------------- /reporter/type-json.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * JSON reporter 4 | */ 5 | 6 | /** 7 | * Reporter 8 | * @param {Object} validationData 9 | * @returns {String} 10 | */ 11 | module.exports = function (validationData) { 12 | return JSON.stringify(validationData, null, 2); 13 | }; 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### JetBrains template 2 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio 3 | 4 | *.iml 5 | 6 | ## Directory-based project format: 7 | .idea/ 8 | # if you remove the above rule, at least ignore the following: 9 | 10 | ## File-based project format: 11 | *.ipr 12 | *.iws 13 | 14 | ## Node.js 15 | node_modules 16 | npm-debug.log 17 | -------------------------------------------------------------------------------- /reporter/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * Get reporter by options 4 | */ 5 | var thr = require('throw'); 6 | 7 | /** 8 | * Get reporter 9 | * @param {String} [type=text] 10 | * @returns {Function} Reporter 11 | */ 12 | module.exports = function getReporter(type) { 13 | type = type || 'text'; 14 | try { 15 | return require('./type-' + type); 16 | } catch (e) { 17 | thr('Reporter %s not found', type); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /actions/validate-by-w3c.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * Validate feed by W3C validator 4 | */ 5 | var Q = require('q'); 6 | var _ = require('lodash'); 7 | 8 | var getFeed = require('../providers/feed'); 9 | var validateFeed = require('../providers/feed-validator'); 10 | 11 | /** 12 | * Validate 13 | * @param {String} url 14 | * @return {Promise} 15 | */ 16 | module.exports = function (url) { 17 | return getFeed(url) 18 | .then(function (feed) { 19 | return Q.all([ 20 | {feed: feed}, 21 | validateFeed(feed) 22 | ]); 23 | }) 24 | .then(function (results) { 25 | return _.assign({feedJson: results[0].feed}, results[1]); 26 | }); 27 | }; 28 | -------------------------------------------------------------------------------- /providers/feed.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * Feed data provider 4 | */ 5 | var Q = require('q'); 6 | var Http = require('q-io/http'); 7 | var parseXml = require('xml2js').parseString; 8 | var thr = require('throw'); 9 | var validUrl = require('valid-url'); 10 | var readFile = require('fs-readfile-promise'); 11 | 12 | /** 13 | * Get feed from URL or local file 14 | * @param {String} url URI of local file path 15 | * @returns {Promise} Feed JSON representation 16 | */ 17 | module.exports = function feedProvider(url) { 18 | return (validUrl.isUri(url) ? Http.read(url) : readFile(url)) 19 | .catch(function (err) { 20 | thr('Transport error: %s', err); 21 | }) 22 | .then(function (res) { 23 | return Q.nfcall(parseXml, res.toString()); 24 | }) 25 | .catch(function (err) { 26 | thr('Parse error:\n%s', err); 27 | }); 28 | }; 29 | -------------------------------------------------------------------------------- /test/providers/feed.data.js: -------------------------------------------------------------------------------- 1 | // jscs:disable maximumLineLength 2 | 3 | exports.correctXml = '' + 4 | '' + 5 | 'Яндекс' + 6 | 'Воспользуйтесь Яндексом для поиска в Интернете.' + 7 | 'https://yastatic.net/lego/_/pDu9OWAQKB0s2J9IojKpiS_Eho.ico' + 8 | '' + 9 | '' + 10 | 'UTF-8' + 11 | ''; 12 | 13 | exports.invalidXml = '' + 14 | '' + 15 | 'Яндекс'; 16 | -------------------------------------------------------------------------------- /test/actions/validate-by-plugins.test.js: -------------------------------------------------------------------------------- 1 | describe('actions/validate-by-plugins', function () { 2 | var validate; 3 | 4 | before(function () { 5 | validate = require('../../actions/validate-by-plugins'); 6 | }); 7 | 8 | it('should form correct structure from plugins optput', function () { 9 | var result = validate({}, { 10 | plugins: [ 11 | function () { 12 | return {level: 'error', text: 'Test error'}; 13 | }, 14 | function () { 15 | return [{level: 'warning', text: 'Test warning'}]; 16 | }, 17 | function () { 18 | return {level: 'info', text: 'Test info'}; 19 | } 20 | ] 21 | }); 22 | 23 | assert.deepPropertyVal(result, 'errors.0.text', 'Test error'); 24 | assert.deepPropertyVal(result, 'warnings.0.text', 'Test warning'); 25 | assert.deepPropertyVal(result, 'info.0.text', 'Test info'); 26 | 27 | assert.notOk(result.isValid, 'isValid must be falsy when there is errors'); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Andrey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /test/actions/suppress.test.js: -------------------------------------------------------------------------------- 1 | var data = require('./suppress.data'); 2 | 3 | describe('actions/suppress', function () { 4 | var suppress; 5 | 6 | before(function () { 7 | suppress = require('../../actions/suppress'); 8 | }); 9 | 10 | it('should suppress all message types by spec', function () { 11 | var suppressed = suppress(data.invalidData, { 12 | suppress: [ 13 | {level: 'error', type: 'MissingDescription'}, 14 | {level: 'info', line: 23} 15 | ] 16 | }); 17 | 18 | assert.lengthOf(suppressed.errors, 0); 19 | assert.lengthOf(suppressed.info, 0); 20 | 21 | assert.deepPropertyVal(suppressed, 'warnings.0.type', 'MissingDescription'); 22 | }); 23 | 24 | it('should modify isValid flag', function () { 25 | var suppressed = suppress(data.invalidData, { 26 | suppress: [{level: 'error'}, {level: 'warning'}] 27 | }); 28 | 29 | assert.lengthOf(suppressed.errors, 0); 30 | assert.lengthOf(suppressed.warnings, 0); 31 | 32 | assert.ok(suppressed.isValid, 'isValid must be ok'); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /actions/suppress.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * Suppress errors, warnings and info by config 4 | */ 5 | var _ = require('lodash'); 6 | 7 | /** 8 | * Suppress 9 | * @param {Object} validationData 10 | * @param {Object} options 11 | * @param {Array} [options.suppress] 12 | * @returns {Object} New validation data without suppressed messages 13 | */ 14 | module.exports = function suppress(validationData, options) { 15 | validationData = _.cloneDeep(validationData); 16 | if (!options.suppress) { 17 | return validationData; 18 | } 19 | 20 | var suppressRules = [].concat(options.suppress); 21 | _.each(['errors', 'warnings', 'info'], function (field) { 22 | validationData[field] = _.filter(validationData[field], checkMessage); 23 | }); 24 | 25 | validationData.isValid = validationData.isValid || validationData.errors.length == 0; 26 | 27 | function checkMessage(message) { 28 | var matchMessage = _.partial(matchRule, message); 29 | return !_.some(suppressRules, matchMessage); 30 | } 31 | 32 | function matchRule(message, rule) { 33 | return _.every(_.keys(rule), function (field) { 34 | return rule[field] === message[field]; 35 | }); 36 | } 37 | 38 | return validationData; 39 | }; 40 | -------------------------------------------------------------------------------- /actions/validate-by-plugins.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * Validate feed by plugins 4 | */ 5 | var _ = require('lodash'); 6 | 7 | /** 8 | * Validate by plugins 9 | * @param {Object} feedJson 10 | * @param {Object} options 11 | * @param {Function[]} options.plugins 12 | * @returns {Object} 13 | */ 14 | module.exports = function validateByPlugins(feedJson, options) { 15 | var result = { 16 | isValid: true, 17 | errors: [], 18 | warnings: [], 19 | info: [] 20 | }; 21 | 22 | if (!_.get(options, 'plugins.length')) { 23 | return result; 24 | } 25 | 26 | var pluginsResults = _(options.plugins) 27 | .map(function (plugin) { 28 | var res = plugin(_.cloneDeep(feedJson), _.cloneDeep(options)); 29 | return res && [].concat(res); 30 | }) 31 | .compact() 32 | .flatten() 33 | .run(); 34 | 35 | var levelsTable = { 36 | error: 'errors', 37 | warning: 'warnings', 38 | info: 'info' 39 | }; 40 | _.each(pluginsResults, function (pluginRes) { 41 | var listType = levelsTable[pluginRes.level]; 42 | if (listType) { 43 | result[listType].push(pluginRes); 44 | } 45 | }); 46 | 47 | result.isValid = result.errors.length == 0; 48 | return result; 49 | }; 50 | -------------------------------------------------------------------------------- /examples/config.yandex.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * Config example for Yandex opensearch.xml file 4 | */ 5 | var _ = require('lodash'); 6 | 7 | module.exports = { 8 | reporter: 'text', 9 | noColors: false, 10 | noShowFeed: false, 11 | suppress: [ 12 | {level: 'error', text: 'Unexpected method attribute on Url element'}, 13 | {level: 'warning', type: 'ShouldIncludeExample'} 14 | ], 15 | plugins: [ 16 | /** 17 | * Check HTTPS in urls 18 | * @param {Object} feedJson 19 | */ 20 | function checkHttps(feedJson) { 21 | var path = 'OpenSearchDescription.Url'; 22 | var urls = _.get(feedJson, path); 23 | 24 | var errors = []; 25 | if (!urls) { 26 | errors.push({level: 'error', path: path, text: 'No urls'}); 27 | } 28 | 29 | _.each(urls, function (item, i) { 30 | var url = _.get(item, '$.template'); 31 | var type = _.get(item, '$.type'); 32 | 33 | var errPath = [path, i, '$.template'].join('.'); 34 | if (!url) { 35 | errors.push({level: 'error', path: errPath, text: 'No url template for type ' + type}); 36 | } else if (!/(https:)?\/\//.test(url)) { 37 | errors.push({level: 'error', path: errPath, text: 'Non HTTPS schema in type ' + type}); 38 | } 39 | }); 40 | return errors; 41 | } 42 | ] 43 | }; 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Andrey Prokopyuk ", 3 | "name": "feed-validator", 4 | "description": "Simple validator for RSS, Atom or opensearch.xml that using validator.w3.org/feed and plugins", 5 | "version": "1.1.1", 6 | "main": "cli/run.js", 7 | "keywords": [ 8 | "validation", 9 | "validator", 10 | "feed", 11 | "RSS", 12 | "Atom", 13 | "OpenSearch", 14 | "opensearch.xml", 15 | "plugins" 16 | ], 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/Andre-487/feed-validator.git" 20 | }, 21 | "bugs": { 22 | "url": "https://github.com/Andre-487/feed-validator/issues" 23 | }, 24 | "homepage": "https://github.com/Andre-487/feed-validator#readme", 25 | "license": "MIT", 26 | "scripts": { 27 | "test": "cd test && mocha --recursive --reporter spec --require bootstrap.js ." 28 | }, 29 | "engines": { 30 | "node": ">= 6.0.0" 31 | }, 32 | "bin": { 33 | "feed-validator": "./cli/run.js" 34 | }, 35 | "preferGlobal": true, 36 | "dependencies": { 37 | "argparse": "^1.0.2", 38 | "cli-table": "^0.3.1", 39 | "colors": "^1.1.2", 40 | "fs-readfile-promise": "^3.0.0", 41 | "lodash": "^4.17.19", 42 | "q": "^1.4.1", 43 | "q-io": "^1.13.1", 44 | "throw": "^1.0.0", 45 | "valid-url": "^1.0.9", 46 | "xml2js": "^0.4.10" 47 | }, 48 | "devDependencies": { 49 | "chai": "^3.2.0", 50 | "chai-as-promised": "^5.1.0", 51 | "eslint": "^1.3.0", 52 | "git-hooks": "^1.0.0", 53 | "jscs": "^2.1.1", 54 | "mocha": "^2.2.5", 55 | "sinon": "^1.16.1" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /cli/run.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * @file 4 | * Run validation by CLI 5 | */ 6 | var Q = require('q'); 7 | var _ = require('lodash'); 8 | 9 | var getOptions = require('../actions/cli/get-options'); 10 | var suppressMessages = require('../actions/suppress'); 11 | var validateByPlugins = require('../actions/validate-by-plugins'); 12 | var validateByW3c = require('../actions/validate-by-w3c'); 13 | 14 | var getReporter = require('../reporter'); 15 | 16 | function main() { 17 | getOptions() 18 | .then(function (options) { 19 | return Q.all([ 20 | {options: options}, 21 | validateByW3c(options.url) 22 | ]); 23 | }) 24 | .then(function (data) { 25 | var ctx = data[0]; 26 | var validationResult = data[1]; 27 | 28 | var pluginsResult = validateByPlugins(validationResult.feedJson, ctx.options); 29 | validationResult.isValid = validationResult.isValid && pluginsResult.isValid; 30 | _.each(['errors', 'warnings', 'info'], function (listName) { 31 | [].push.apply(validationResult[listName], pluginsResult[listName]); 32 | }); 33 | 34 | return [ctx, validationResult]; 35 | }) 36 | .then(function (data) { 37 | var ctx = data[0]; 38 | var validationData = suppressMessages(data[1], ctx.options); 39 | 40 | var reporter = getReporter(ctx.options.reporter); 41 | console.log(reporter(validationData, ctx.options)); 42 | 43 | process.exit(validationData.isValid ? 0 : 1); 44 | }) 45 | .done(); 46 | } 47 | 48 | if (module === require.main) { 49 | main(); 50 | } 51 | -------------------------------------------------------------------------------- /test/reporter.test.js: -------------------------------------------------------------------------------- 1 | var getReporter = require('../reporter'); 2 | var data = require('./reporter.data.js'); 3 | 4 | describe('reporter/type-text', function () { 5 | var reporter; 6 | 7 | before(function () { 8 | reporter = getReporter('text'); 9 | }); 10 | 11 | it('should form correct sections with invalid data', function () { 12 | var text = reporter(data.invalidData); 13 | 14 | assert.include(text, 'Validation results'); 15 | assert.include(text, 'Feed:'); 16 | assert.include(text, 'Errors:'); 17 | assert.include(text, 'Warnings:'); 18 | assert.include(text, 'Info:'); 19 | }); 20 | 21 | it('should form correct sections with valid data', function () { 22 | var text = reporter(data.validData); 23 | 24 | assert.include(text, 'Validation results'); 25 | assert.include(text, 'Feed:'); 26 | assert.include(text, 'All correct'); 27 | 28 | assert.notInclude(text, 'Errors:'); 29 | assert.notInclude(text, 'Warnings:'); 30 | assert.notInclude(text, 'Info:'); 31 | }); 32 | }); 33 | 34 | describe('reporter/json', function () { 35 | var reporter; 36 | 37 | before(function () { 38 | reporter = getReporter('json'); 39 | }); 40 | 41 | it('should form correct json with invalid data', function () { 42 | var text = reporter(data.invalidData); 43 | var parsedJson = JSON.parse(text); 44 | 45 | assert.property(parsedJson, 'isValid'); 46 | assert.property(parsedJson, 'feedXml'); 47 | assert.property(parsedJson, 'feedJson'); 48 | 49 | assert.property(parsedJson, 'errors'); 50 | assert.property(parsedJson, 'warnings'); 51 | assert.property(parsedJson, 'info'); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /.githooks/pre-commit/lint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | COLOR_RED="\e[0;31m" 5 | COLOR_GREEN="\e[0;32m" 6 | COLOR_YELLOW="\e[0;33m" 7 | COLOR_RESET="\e[0m" 8 | 9 | SEPARATOR="${COLOR_YELLOW}----------${COLOR_RESET}" 10 | 11 | script_dir=$(cd `dirname $0` && pwd -P) 12 | project_dir=`cd "$script_dir/../.." && pwd -P` 13 | npm_bin_dir=`cd "$project_dir" && npm bin` 14 | 15 | patch_file=`mktemp -t "working-tree.patch.XXXXXXXXX"` 16 | 17 | function cleanup { 18 | exit_code=$? 19 | if [[ -f "$patch_file" ]]; then 20 | patch -p0 < "$patch_file" 21 | rm "$patch_file" 22 | fi 23 | exit "$exit_code" 24 | } 25 | 26 | # Catch an exit event 27 | trap cleanup EXIT SIGINT SIGHUP 28 | 29 | # Cancel any changes to the working tree that are not in the git index 30 | git diff --no-prefix > "$patch_file" 31 | git checkout -- . 32 | 33 | git_cached_files=$(git diff --cached --name-only --diff-filter=ACMR) 34 | git_cached_js=$(echo "$git_cached_files" | grep '\.js$' | xargs echo) 35 | 36 | error=0 37 | 38 | eslint_st=0 39 | jscs_st=0 40 | 41 | set +e 42 | 43 | if [[ "$git_cached_js" ]]; then 44 | "$npm_bin_dir/eslint" ${git_cached_js} 45 | if [[ $? != 0 ]]; then 46 | error=1 47 | eslint_st=1 48 | echo -e "$SEPARATOR" 49 | fi 50 | 51 | "$npm_bin_dir/jscs" ${git_cached_js} --config "$project_dir/.jscs.json" 52 | if [[ $? != 0 ]]; then 53 | error=1 54 | jscs_st=1 55 | echo -e "$SEPARATOR" 56 | fi 57 | fi 58 | 59 | if [[ "$error" != 0 ]]; then 60 | echo -en "$COLOR_RED" 61 | 62 | echo "Fail! ☹ You have an issues in the following linters:" 63 | [[ "$eslint_st" != 0 ]] && echo " ☢ ESLint" 64 | [[ "$jscs_st" != 0 ]] && echo " ☢ JSCS" 65 | 66 | echo -en "$COLOR_RESET" 67 | exit 1 68 | fi 69 | 70 | echo -e "${COLOR_GREEN}Pre-commit lint: OK${COLOR_RESET}" 71 | -------------------------------------------------------------------------------- /test/providers/feed.test.js: -------------------------------------------------------------------------------- 1 | var Q = require('q'); 2 | var Http = require('q-io/http'); 3 | var _ = require('lodash'); 4 | 5 | var data = require('./feed.data.js'); 6 | 7 | describe('providers/feed', function () { 8 | var sandbox; 9 | var getFeed; 10 | 11 | before(function () { 12 | getFeed = require('../../providers/feed'); 13 | }); 14 | 15 | beforeEach(function () { 16 | sandbox = sinon.sandbox.create(); 17 | }); 18 | 19 | afterEach(function () { 20 | sandbox.restore(); 21 | }); 22 | 23 | describe('transport', function () { 24 | it('should return fulfilled promise with correct response', function () { 25 | sandbox.stub(Http, 'read', _.constant(Q(data.correctXml))); 26 | 27 | return assert.isFulfilled(getFeed('http://yandex.ru/opensearch.xml')); 28 | }); 29 | 30 | it('should reject when HTTP error', function () { 31 | sandbox.stub(Http, 'read', _.constant(Q.reject('HTTP error'))); 32 | 33 | return assert.isRejected(getFeed('http://yandex.ru/opensearch.xml'), /Transport error: HTTP error/); 34 | }); 35 | 36 | it('should reject when data is invalid', function () { 37 | sandbox.stub(Http, 'read', _.constant(Q(data.invalidXml))); 38 | 39 | return assert.isRejected(getFeed('http://yandex.ru/opensearch.xml'), /Parse error:/); 40 | }); 41 | }); 42 | 43 | describe('parsing', function () { 44 | it('should provide data JSON representation', function () { 45 | sandbox.stub(Http, 'read', _.constant(Q(data.correctXml))); 46 | 47 | return getFeed('http://yandex.ru/opensearch.xml') 48 | .then(function (data) { 49 | assert.deepPropertyVal(data, 'OpenSearchDescription.ShortName.0', 'Яндекс'); 50 | }); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /actions/cli/get-options.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * Get options from arguments and config file 4 | */ 5 | var Path = require('path'); 6 | 7 | var ArgumentParser = require('argparse').ArgumentParser; 8 | var Fs = require('q-io/fs'); 9 | var Q = require('q'); 10 | var thr = require('throw'); 11 | var _ = require('lodash'); 12 | 13 | var packageInfo = require('../../package.json'); 14 | 15 | /** 16 | * Get options 17 | * @returns {Promise} Options 18 | */ 19 | module.exports = function () { 20 | var parser = new ArgumentParser({ 21 | prog: packageInfo.name, 22 | description: packageInfo.description, 23 | version: packageInfo.version, 24 | addHelp: true, 25 | epilog: 'Feed validator: Of Steamworks and Magick Obscura' 26 | }); 27 | parser.addArgument(['-c', '--config'], { 28 | action: 'store', 29 | metavar: 'FILE_PATH', 30 | required: false, 31 | help: 'Config file path' 32 | }); 33 | parser.addArgument(['-r', '--reporter'], { 34 | action: 'store', 35 | metavar: 'REPORTER_NAME', 36 | required: false, 37 | defaultValue: 'text', 38 | choices: ['text', 'json'], 39 | help: 'Reporter name: text, json' 40 | }); 41 | parser.addArgument(['--no-colors'], { 42 | action: 'storeTrue', 43 | dest: 'noColors', 44 | help: 'Don\'t use colors' 45 | }); 46 | parser.addArgument(['--no-showfeed'], { 47 | action: 'storeTrue', 48 | dest: 'noShowFeed', 49 | help: 'Don\'t show the full feed' 50 | }); 51 | parser.addArgument(['url'], { 52 | action: 'store', 53 | help: 'Feed url to validate' 54 | }); 55 | 56 | var args = parser.parseArgs(); 57 | if (!args.config) { 58 | return Q(args); 59 | } 60 | 61 | var configPath = Path.join(process.cwd(), args.config); 62 | return Fs.exists(configPath) 63 | .then(function (exists) { 64 | if (!exists) { 65 | thr('Config not found in path %s', args.config); 66 | } 67 | return _.assign({}, require(configPath), args); 68 | }); 69 | }; 70 | -------------------------------------------------------------------------------- /test/actions/suppress.data.js: -------------------------------------------------------------------------------- 1 | // jscs:disable maximumLineLength 2 | 3 | exports.invalidData = { 4 | feedJson: { 5 | OpenSearchDescription: { 6 | $: {xmlns: 'http://a9.com/-/spec/opensearch/1.1/'}, 7 | ShortName: ['Яндекс'], 8 | Description: ['Воспользуйтесь Яндексом для поиска в Интернете.'], 9 | Image: [''], 10 | Url: ['', ''], 11 | InputEncoding: ['UTF-8'] 12 | } 13 | }, 14 | feedXml: '\n' + 15 | '\n' + 16 | 'Яндекс\n' + 17 | 'Воспользуйтесь Яндексом для поиска в Интернете.\n' + 18 | 'https://yastatic.net/lego/_/pDu9OWAQKB0s2J9IojKpiS_Eho.ico\n' + 19 | '\n' + 20 | '\n' + 21 | 'UTF-8\n' + 22 | '', 23 | isValid: false, 24 | errors: [ 25 | { 26 | level: 'error', 27 | type: 'MissingDescription', 28 | line: 23, 29 | column: 0, 30 | text: 'Missing channel element: description', 31 | msgcount: 1, 32 | backupcolumn: 0, 33 | backupline: 23, 34 | element: 'description', 35 | parent: 'channel' 36 | } 37 | ], 38 | warnings: [ 39 | { 40 | level: 'warning', 41 | type: 'MissingDescription', 42 | line: 23, 43 | column: 0, 44 | text: 'Missing channel element: description', 45 | msgcount: 1, 46 | backupcolumn: 0, 47 | backupline: 23, 48 | element: 'description', 49 | parent: 'channel' 50 | } 51 | ], 52 | info: [ 53 | { 54 | level: 'info', 55 | type: 'MissingDescription', 56 | line: 23, 57 | column: 0, 58 | text: 'Missing channel element: description', 59 | msgcount: 1, 60 | backupcolumn: 0, 61 | backupline: 23, 62 | element: 'description', 63 | parent: 'channel' 64 | } 65 | ] 66 | }; 67 | -------------------------------------------------------------------------------- /reporter/type-text.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * Text reporter 4 | */ 5 | var Colors = require('colors/safe'); 6 | var Table = require('cli-table'); 7 | var _ = require('lodash'); 8 | 9 | /** 10 | * Text reporter 11 | * @param {Object} validationData 12 | * @param {Object} [options] 13 | * @param {Boolean} options.noColors 14 | * @param {Boolean} options.noShowFeed 15 | * @returns {String} 16 | */ 17 | module.exports = function textReporter(validationData, options) { 18 | options = options || {}; 19 | 20 | function mapItem(item) { 21 | return [ 22 | item.line ? 23 | 'At line: ' + item.line + ', column: ' + item.column : 24 | 'At path: ' + item.path, 25 | String(item.type || 'CustomMessage'), 26 | String(item.text || '') 27 | ]; 28 | } 29 | 30 | function prepareString(str, color) { 31 | return options.noColors ? str : Colors[color](str); 32 | } 33 | 34 | function createTable(items) { 35 | var table = new Table({ 36 | chars: { 37 | top: '', 'top-mid': '', 'top-left': '', 'top-right': '', 38 | bottom: '', 'bottom-mid': '', 'bottom-left': '', 'bottom-right': '', 39 | left: '', 'left-mid': '', mid: '', 'mid-mid': '', 40 | right: '', 'right-mid': '', middle: '' 41 | }, 42 | style: { 43 | 'padding-top': 0, 'padding-bottom': 0, 44 | 'padding-left': 2, 'padding-right': 2 45 | } 46 | }); 47 | _.each(items, function (item) { 48 | table.push(item); 49 | }); 50 | return table.toString(); 51 | } 52 | 53 | return _([ 54 | prepareString('Validation results', 'magenta'), 55 | '', 56 | !options.noShowFeed ? [ 57 | prepareString('Feed:', 'green'), 58 | validationData.feedXml, 59 | '' 60 | ] : null, 61 | validationData.errors.length ? [ 62 | prepareString('Errors:', 'red'), 63 | createTable(_.map(validationData.errors, mapItem)), 64 | '' 65 | ] : null, 66 | validationData.warnings.length ? [ 67 | prepareString('Warnings:', 'blue'), 68 | createTable(_.map(validationData.warnings, mapItem)), 69 | '' 70 | ] : null, 71 | validationData.info.length ? [ 72 | prepareString('Info:', 'yellow'), 73 | createTable(_.map(validationData.info, mapItem)), 74 | '' 75 | ] : null, 76 | validationData.isValid ? prepareString('All correct', 'green') : null 77 | ]) 78 | .flatten() 79 | .filter(function (item) { return item !== null; }) 80 | .run() 81 | .join('\n'); 82 | }; 83 | -------------------------------------------------------------------------------- /test/providers/feed-validator.test.js: -------------------------------------------------------------------------------- 1 | var Q = require('q'); 2 | var Http = require('q-io/http'); 3 | var _ = require('lodash'); 4 | 5 | var data = require('./feed-validator.data'); 6 | 7 | describe('providers/feed-validator', function () { 8 | var sandbox; 9 | var feedValidator; 10 | 11 | before(function () { 12 | feedValidator = require('../../providers/feed-validator'); 13 | }); 14 | 15 | beforeEach(function () { 16 | sandbox = sinon.sandbox.create(); 17 | }); 18 | 19 | afterEach(function () { 20 | sandbox.restore(); 21 | }); 22 | 23 | it('should return correct structure', function () { 24 | useFakeValidatorResponse(); 25 | 26 | feedValidator(data.dataJson) 27 | .then(function (data) { 28 | assert.property(data, 'xml'); 29 | assert.property(data, 'isValid'); 30 | assert.property(data, 'errors'); 31 | assert.property(data, 'warnings'); 32 | assert.property(data, 'info'); 33 | }); 34 | }); 35 | 36 | describe('#stringifyXml()', function () { 37 | it('should stringify correct data', function () { 38 | var res = feedValidator.stringifyXml(data.dataJson); 39 | assert.include(res, ''); 40 | }); 41 | }); 42 | 43 | describe('#makeValidationRequest()', function () { 44 | it('should provide validator response as JSON', function () { 45 | useFakeValidatorResponse(); 46 | 47 | return feedValidator.makeValidationRequest(data.correctXml) 48 | .then(function (data) { 49 | assert.deepPropertyVal(data, 'env:Envelope.env:Body.0.m:feedvalidationresponse.0.m:uri.0', 50 | 'http://www.w3.org/QA/news.rss'); 51 | }); 52 | }); 53 | }); 54 | 55 | describe('#extractSenseFromResponse()', function () { 56 | it('should throw error when no container', function () { 57 | assert.throws(_.partial(feedValidator.extractSenseFromResponse, {}), /No container/); 58 | }); 59 | 60 | it('should extract errors from JSON representation os SOAP response', function () { 61 | var res = feedValidator.extractSenseFromResponse(data.validatorResponseJson); 62 | 63 | assert.propertyVal(res, 'isValid', false); 64 | assert.deepPropertyVal(res, 'errors.0.level', 'error'); 65 | assert.deepPropertyVal(res, 'warnings.0.level', 'warning'); 66 | assert.deepPropertyVal(res, 'info.0.level', 'info'); 67 | }); 68 | }); 69 | 70 | function useFakeValidatorResponse() { 71 | sandbox.stub(Http, 'request', function () { 72 | return Q(_.set({}, 'body.read', _.constant(data.validatorResponse))); 73 | }); 74 | } 75 | }); 76 | -------------------------------------------------------------------------------- /providers/feed-validator.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * Provider for feed validator data 4 | * @see https://validator.w3.org/feed/docs/soap.html 5 | */ 6 | var Http = require('q-io/http'); 7 | var Q = require('q'); 8 | var thr = require('throw'); 9 | var Xml2js = require('xml2js'); 10 | var _ = require('lodash'); 11 | 12 | var VALIDATOR_URL = 'http://validator.w3.org/feed/check.cgi'; 13 | 14 | /** 15 | * Validate feed by validator.w3.org/feed 16 | * @type {Function} 17 | * @param {Object} dataJson Feed JSON representation 18 | * @returns {Promise} Validation result 19 | */ 20 | var feedValidator = module.exports = function feedValidator(dataJson) { 21 | var xml = feedValidator.stringifyXml(dataJson); 22 | return feedValidator.makeValidationRequest(xml) 23 | .then(function (resp) { 24 | return _.assign({feedXml: xml}, feedValidator.extractSenseFromResponse(resp)); 25 | }); 26 | }; 27 | 28 | /** 29 | * Stringify XML JSON representation 30 | * @param {Object} dataJson 31 | * @returns {String} XML 32 | */ 33 | feedValidator.stringifyXml = function (dataJson) { 34 | var builder = new Xml2js.Builder(); 35 | return builder.buildObject(dataJson); 36 | }; 37 | 38 | /** 39 | * Send validation request 40 | * @param {String} xml 41 | * @returns {Promise} Response data as JSON 42 | */ 43 | feedValidator.makeValidationRequest = function (xml) { 44 | return Http 45 | .request({ 46 | url: VALIDATOR_URL, 47 | method: 'POST', 48 | headers: {'Content-type': 'application/x-www-form-urlencoded'}, 49 | body: ['manual=1&output=soap12&rawdata=' + encodeURIComponent(xml)] 50 | }) 51 | .then(function (res) { 52 | return res.body.read(); 53 | }) 54 | .then(function (body) { 55 | return Q.nfcall(Xml2js.parseString, body); 56 | }); 57 | }; 58 | 59 | /** 60 | * Extract important information from validator response 61 | * @param {Object} response Validator response JSON representation 62 | * @returns {{isValid: Boolean, errors: Object[], warnings: Object[], info: Object[]}} 63 | */ 64 | feedValidator.extractSenseFromResponse = function (response) { 65 | var container = _.get(response, 'env:Envelope.env:Body.0.m:feedvalidationresponse.0') || thr('No container'); 66 | var validity = _.get(container, 'm:validity.0'); 67 | 68 | function mapItem(item) { 69 | return _.transform(item, function (res, arr, name) { 70 | var val = arr[0]; 71 | if (/^\d+$/.test(val)) { 72 | val = Number(val); 73 | } 74 | res[name] = val; 75 | return res; 76 | }); 77 | } 78 | 79 | return { 80 | isValid: validity == 'true' || validity == 'false' ? validity == 'true' : null, 81 | errors: _.map(_.get(container, 'm:errors.0.m:errorlist.0.error'), mapItem), 82 | warnings: _.map(_.get(container, 'm:warnings.0.m:warninglist.0.warning'), mapItem), 83 | info: _.map(_.get(container, 'm:informations.0.m:infolist.0.info'), mapItem) 84 | }; 85 | }; 86 | -------------------------------------------------------------------------------- /test/reporter.data.js: -------------------------------------------------------------------------------- 1 | // jscs:disable maximumLineLength 2 | 3 | exports.invalidData = { 4 | feedJson: { 5 | OpenSearchDescription: { 6 | $: {xmlns: 'http://a9.com/-/spec/opensearch/1.1/'}, 7 | ShortName: ['Яндекс'], 8 | Description: ['Воспользуйтесь Яндексом для поиска в Интернете.'], 9 | Image: [''], 10 | Url: ['', ''], 11 | InputEncoding: ['UTF-8'] 12 | } 13 | }, 14 | feedXml: '\n' + 15 | '\n' + 16 | 'Яндекс\n' + 17 | 'Воспользуйтесь Яндексом для поиска в Интернете.\n' + 18 | 'https://yastatic.net/lego/_/pDu9OWAQKB0s2J9IojKpiS_Eho.ico\n' + 19 | '\n' + 20 | '\n' + 21 | 'UTF-8\n' + 22 | '', 23 | isValid: false, 24 | errors: [ 25 | { 26 | level: 'error', 27 | type: 'MissingDescription', 28 | line: 23, 29 | column: 0, 30 | text: 'Missing channel element: description', 31 | msgcount: 1, 32 | backupcolumn: 0, 33 | backupline: 23, 34 | element: 'description', 35 | parent: 'channel' 36 | } 37 | ], 38 | warnings: [ 39 | { 40 | level: 'warning', 41 | type: 'MissingDescription', 42 | line: 23, 43 | column: 0, 44 | text: 'Missing channel element: description', 45 | msgcount: 1, 46 | backupcolumn: 0, 47 | backupline: 23, 48 | element: 'description', 49 | parent: 'channel' 50 | } 51 | ], 52 | info: [ 53 | { 54 | level: 'info', 55 | type: 'MissingDescription', 56 | line: 23, 57 | column: 0, 58 | text: 'Missing channel element: description', 59 | msgcount: 1, 60 | backupcolumn: 0, 61 | backupline: 23, 62 | element: 'description', 63 | parent: 'channel' 64 | } 65 | ] 66 | }; 67 | 68 | exports.validData = { 69 | feedJson: { 70 | OpenSearchDescription: { 71 | $: {xmlns: 'http://a9.com/-/spec/opensearch/1.1/'}, 72 | ShortName: ['Яндекс'], 73 | Description: ['Воспользуйтесь Яндексом для поиска в Интернете.'], 74 | Image: [''], 75 | Url: ['', ''], 76 | InputEncoding: ['UTF-8'] 77 | } 78 | }, 79 | feedXml: '\n' + 80 | '\n' + 81 | 'Яндекс\n' + 82 | 'Воспользуйтесь Яндексом для поиска в Интернете.\n' + 83 | 'https://yastatic.net/lego/_/pDu9OWAQKB0s2J9IojKpiS_Eho.ico\n' + 84 | '\n' + 85 | '\n' + 86 | 'UTF-8\n' + 87 | '', 88 | isValid: true, 89 | errors: [], 90 | warnings: [], 91 | info: [] 92 | }; 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Feed validator 2 | Simple validator for feeds like RSS or Atom. Supports opensearch.xml validation. 3 | Based on validator.w3.org/feed 4 | 5 | Supports plugins for custom checks 6 | 7 | [![Build Status](https://travis-ci.org/andre487/feed-validator.svg?branch=master)](https://travis-ci.org/andre487/feed-validator) 8 | [![Code Climate](https://codeclimate.com/github/Andre-487/feed-validator/badges/gpa.svg)](https://codeclimate.com/github/Andre-487/feed-validator) 9 | [![bitHound Overall Score](https://www.bithound.io/github/andre487/feed-validator/badges/score.svg)](https://www.bithound.io/github/andre487/feed-validator) 10 | [![npm version](https://badge.fury.io/js/feed-validator.svg)](http://badge.fury.io/js/feed-validator) 11 | 12 | ## Installation 13 | ``` 14 | npm install [-g] feed-validator 15 | ``` 16 | 17 | ## Usage 18 | ``` 19 | usage: feed-validator [-h] [-v] [-c FILE_PATH] [-r REPORTER_NAME] 20 | [--no-colors] 21 | url 22 | 23 | Simple validator for RSS, Atom or opensearch.xml that using validator.w3. 24 | org/feed and plugins 25 | 26 | Positional arguments: 27 | url Feed url or file-path to validate 28 | 29 | Optional arguments: 30 | -h, --help Show this help message and exit. 31 | -v, --version Show program's version number and exit. 32 | -c FILE_PATH, --config FILE_PATH 33 | Config file path 34 | -r REPORTER_NAME, --reporter REPORTER_NAME 35 | Reporter name: text, json 36 | --no-colors Don't use colors 37 | --no-showfeed Don't show the feed content 38 | ``` 39 | 40 | ## Arguments and options 41 | Options can be defined by command line and configuration file. 42 | 43 | ### url 44 | URL or file-path of the validated feed. 45 | 46 | ### config 47 | Configuration file. Can be passed from command line. Example of config file see in `examples` folder. 48 | 49 | ### reporter 50 | Reporter type: text or JSON. Can be defined in command line: `--reporter json` or in config file: `reporter: 'json'` 51 | 52 | ### noColors 53 | Don't use colors in report. Can be passed from command line: `--no-colors` and from config file: `noColors: true`. 54 | 55 | ### noShowFeed 56 | Don't show the feed's xml content in the report. Can be passed from command line: `--no-showfeed` and from config file: `noShowFeed: true`. 57 | 58 | ### suppress 59 | You can suppress some messages by defining objects that contains fields to match in config file. 60 | Example of suppressing: 61 | ```js 62 | suppress: [ 63 | {level: 'error', text: 'Unexpected method attribute on Url element'}, 64 | {level: 'warning', type: 'ShouldIncludeExample'} 65 | ], 66 | ``` 67 | 68 | ### plugins 69 | Can be defined in config file (see `examples`). Each plugin is function that take JSON feed representation and returns errors, 70 | warnings and information messages list. 71 | 72 | Plugin function example: 73 | ```js 74 | /** 75 | * Check HTTPS in urls from opensearch.xml 76 | * @param {Object} feedJson Feed JSON representation 77 | * @param {Object} options Program options 78 | */ 79 | function checkHttps(feedJson, options) { 80 | var path = 'OpenSearchDescription.Url'; 81 | var urls = _.get(feedJson, path); 82 | 83 | var errors = []; 84 | if (!urls) { 85 | errors.push({level: 'error', path: path, text: 'No urls'}); 86 | } 87 | 88 | _.each(urls, function (item, i) { 89 | var url = _.get(item, '$.template'); 90 | var type = _.get(item, '$.type'); 91 | 92 | var errPath = [path, i, '$.template'].join('.'); 93 | if (!url) { 94 | errors.push({level: 'error', path: errPath, text: 'No url template for type ' + type}); 95 | } else if (!/(https:)?\/\//.test(url)) { 96 | errors.push({level: 'error', path: errPath, text: 'Non HTTPS schema in type ' + type}); 97 | } 98 | }); 99 | return errors; 100 | } 101 | ``` 102 | You should define `level` and `text` fields. And you can define your own custom `type` field. 103 | -------------------------------------------------------------------------------- /test/providers/feed-validator.data.js: -------------------------------------------------------------------------------- 1 | // jscs:disable maximumLineLength 2 | 3 | exports.dataJson = { 4 | OpenSearchDescription: { 5 | $: {xmlns: 'http://a9.com/-/spec/opensearch/1.1/'}, 6 | ShortName: ['Яндекс'], 7 | Description: ['Воспользуйтесь Яндексом для поиска в Интернете.'], 8 | Image: [''], 9 | Url: ['', ''], 10 | InputEncoding: ['UTF-8'] 11 | } 12 | }; 13 | 14 | exports.correctXml = '' + 15 | '' + 16 | 'Яндекс' + 17 | 'Воспользуйтесь Яндексом для поиска в Интернете.' + 18 | 'https://yastatic.net/lego/_/pDu9OWAQKB0s2J9IojKpiS_Eho.ico' + 19 | '' + 20 | '' + 21 | 'UTF-8' + 22 | ''; 23 | 24 | exports.validatorResponse = '\n' + 25 | '' + 26 | '' + 27 | '' + 30 | 'http://www.w3.org/QA/news.rss' + 31 | 'http://qa-dev.w3.org/feed/check.cgi' + 32 | '2005-11-11T11:48:24.491627' + 33 | 'false' + 34 | '' + 35 | '2' + 36 | '' + 37 | '' + 38 | 'error' + 39 | 'MissingDescription' + 40 | '23' + 41 | '0' + 42 | 'Missing channel element: description' + 43 | '1' + 44 | '0' + 45 | '23' + 46 | 'description' + 47 | 'channel' + 48 | '' + 49 | '' + 50 | '' + 51 | '' + 52 | '0' + 53 | '' + 54 | '' + 55 | ' ' + 56 | '0' + 57 | '' + 58 | '' + 59 | '' + 60 | '' + 61 | ''; 62 | 63 | exports.validatorResponseJson = { 64 | 'env:Envelope': { 65 | $: {'xmlns:env': 'http://www.w3.org/2003/05/soap-envelope'}, 66 | 'env:Body': [{ 67 | 'm:feedvalidationresponse': [{ 68 | $: { 69 | 'env:encodingStyle': 'http://www.w3.org/2003/05/soap-encoding', 70 | 'xmlns:m': 'http://www.w3.org/2005/10/feed-validator' 71 | }, 72 | 'm:uri': ['http://www.w3.org/QA/news.rss'], 73 | 'm:checkedby': ['http://qa-dev.w3.org/feed/check.cgi'], 74 | 'm:date': ['2005-11-11T11:48:24.491627'], 75 | 'm:validity': ['false'], 76 | 'm:errors': [{ 77 | 'm:errorcount': ['2'], 78 | 'm:errorlist': [{ 79 | error: [{ 80 | level: ['error'], 81 | type: ['MissingDescription'], 82 | line: ['23'], 83 | column: ['0'], 84 | text: ['Missing channel element: description'], 85 | msgcount: ['1'], 86 | backupcolumn: ['0'], 87 | backupline: ['23'], 88 | element: ['description'], 89 | parent: ['channel'] 90 | }] 91 | }] 92 | }], 93 | 'm:warnings': [{ 94 | 'm:warningcount': ['1'], 95 | 'm:warninglist': [{ 96 | warning: [{ 97 | level: ['warning'], 98 | type: ['MissingDescription'], 99 | line: ['23'], 100 | column: ['0'], 101 | text: ['Missing channel element: description'], 102 | msgcount: ['1'], 103 | backupcolumn: ['0'], 104 | backupline: ['23'], 105 | element: ['description'], 106 | parent: ['channel'] 107 | }] 108 | }] 109 | }], 110 | 'm:informations': [{ 111 | 'm:infocount': ['0'], 112 | 'm:infolist': [{ 113 | info: [{ 114 | level: ['info'], 115 | type: ['MissingDescription'], 116 | line: ['23'], 117 | column: ['0'], 118 | text: ['Missing channel element: description'], 119 | msgcount: ['1'], 120 | backupcolumn: ['0'], 121 | backupline: ['23'], 122 | element: ['description'], 123 | parent: ['channel'] 124 | }] 125 | }] 126 | }] 127 | }] 128 | }] 129 | } 130 | }; 131 | 132 | --------------------------------------------------------------------------------