caniuse-db
Version:
Raw browser/feature support data from caniuse.com
371 lines (345 loc) • 14.5 kB
JavaScript
/* global require,console,__dirname */
/* Node script to validate caniuse feature JSONs */
(function () {
var fs = require('fs');
var path = __dirname + '/../features-json';
var sampleData;
var w3cStatusArr = ['rec', 'pr', 'cr', 'wd'];
var statusArr = w3cStatusArr.concat(['ls', 'other', 'unoff']);
var categoryArr = ['HTML5', 'CSS', 'CSS2', 'CSS3', 'SVG', 'PNG', 'JS API', 'Canvas', 'DOM', 'Other', 'JS', 'Security'];
// Support string MUST have one of these (optionally others)
var supportValues = ['y', 'a', 'n', 'u', 'p'];
var validationFn = {
isString: function (val) {
return typeof val === 'string';
},
isObject: function (val) {
return typeof val === 'object';
},
isArray: function (val) {
return val instanceof Array;
},
isURL: function (val) {
// Source: https://gist.github.com/dperini/729294
var pattern = new RegExp(
"^" +
// protocol identifier
"(?:(?:https?|ftp)://)" +
// user:pass authentication
"(?:\\S+(?::\\S*)?@)?" +
"(?:" +
// IP address exclusion
// private & local networks
"(?!10(?:\\.\\d{1,3}){3})" +
"(?!127(?:\\.\\d{1,3}){3})" +
"(?!169\\.254(?:\\.\\d{1,3}){2})" +
"(?!192\\.168(?:\\.\\d{1,3}){2})" +
"(?!172\\.(?:1[6-9]|2\\d|3[0-1])(?:\\.\\d{1,3}){2})" +
// IP address dotted notation octets
// excludes loopback network 0.0.0.0
// excludes reserved space >= 224.0.0.0
// excludes network & broadcast addresses
// (first & last IP address of each class)
"(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])" +
"(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}" +
"(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))" +
"|" +
// host name
"(?:(?:[a-z\\u00a1-\\uffff0-9]+-?)*[a-z\\u00a1-\\uffff0-9]+)" +
// domain name
"(?:\\.(?:[a-z\\u00a1-\\uffff0-9]+-?)*[a-z\\u00a1-\\uffff0-9]+)*" +
// TLD identifier
"(?:\\.(?:[a-z\\u00a1-\\uffff]{2,}))" +
")" +
// port number
"(?::\\d{2,5})?" +
// resource path
"(?:/[^\\s]*)?" +
"$", "i"
);
return pattern.test(val);
},
isStatus: function (val) {
return statusArr.indexOf(val) > -1;
},
atLeastOne: function (arr) {
return arr.length >= 1;
},
hasCategories: function (arr) {
for (var i = 0; i < arr.length; i++) {
if (categoryArr.indexOf(arr[i]) === -1) {
return false;
}
}
return true;
},
isNumber: function (val) {
return typeof val === 'number';
},
isBoolean: function (val) {
return typeof val === 'boolean';
}
};
var Validator = function (type, id, data) {
this.throwError = function (message) {
var pre = '[' + id;
if( this.currentBrowser ) {
pre += ':' + this.currentBrowser;
}
if( this.currentVersion ) {
pre += ':' + this.currentVersion;
}
pre += '] ';
throw Error(pre + message);
};
this.warn = function (message) {
try {
console.warn('[' + id + '] WARNING: ' + message);
} catch(e){}
};
this.validateArray = function (template, arr) {
for (var i = 0; i < arr.length; i++) {
var itemToValidate = arr[i];
for (var key in template) {
var itemRules = template[key];
this.validate(key, itemRules, itemToValidate);
}
this.validateKeys('array', template, itemToValidate);
}
};
this.validate = function (key, rules, altObject) {
var object = altObject || data;
if (!(key in object)) {
this.throwError('"' + key + '" missing in data');
}
var val = object[key];
if (typeof rules === 'function') {
var validatorFn = rules;
validatorFn(val);
return;
}
for (var i = 0; i < rules.length; i++) {
var rule = rules[i];
if (typeof rule == 'string') {
if (!validationFn[rule](val)) {
this.throwError('Failed ' + rule + ' validation on "' + key + '". Got this: ' + val);
}
} else if (rule instanceof Array) {
this.validateArray(rule[0], val);
}
}
};
this.validateToken = function (token) {
// Must be any of these letters or #1, #2, etc.
if (!/^(y|a|n|u|p|x|d|(\#\d+))$/.test(token)) {
this.throwError('Invalid token: ' + token);
}
};
this.validateSupportValue = function (val) {
if (!validationFn.isString(val)) {
this.throwError('Expected ' + val + ' to be a string');
}
var tokens = val.split(' ');
var doneTokens = {};
// Must have exactly one of these
var gotSupportToken = false;
for (var i = 0; i < tokens.length; i++) {
var token = tokens[i];
if (doneTokens[token]) {
this.throwError('Duplicate token: ' + token);
}
doneTokens[token] = true;
this.validateToken(token);
if (supportValues.indexOf(token) > -1) {
if (gotSupportToken) {
this.throwError('Duplicate support token: ' + token);
}
gotSupportToken = true;
}
}
if (!gotSupportToken) {
this.throwError('No support token found');
}
};
this.validateStatusOfSpec = function () {
if (/^https?:\/\/(?:[^.]+)\.spec\.whatwg\.org\//.test(data.spec)) {
if (w3cStatusArr.indexOf(data.status) > -1) {
this.throwError('W3C status "' + data.status + '" not valid for WHATWG spec "' + data.spec + '"')
}
}
};
// Ensures that there are no dangling notes with either a missing description or missing references
this.validateNoteCoherence = function () {
var statReferences = new Set();
for (var browserStats of Object.values(data.stats)) {
for (var versionStat of Object.values(browserStats)) {
// This regex assumes that the value is syntactically correct
var versionRefs = versionStat.matchAll(/ #(?<number>\d+)/gu);
for (var reference of versionRefs) {
statReferences.add(reference.groups.number);
}
}
}
var notes = new Set(Object.keys(data.notes_by_num));
var allReferences = new Set([...statReferences, ...notes]);
for(var reference of allReferences) {
if(!statReferences.has(reference)){
this.throwError('Note #' + reference + ' is never referenced in stats, and will therefore not be shown');
}
if(!notes.has(reference)){
this.throwError('Found a reference to note #' + reference + ', but that note does not exist');
}
}
};
this.validateKeys = function(parentKey, refObject, object) {
for( var key in object ) {
if( !(key in refObject) ) {
this.throwError('Extra key found in ' + parentKey + ': "' + key + '"');
}
}
};
this.validateSupportData = function () {
var sampleStats = sampleData.stats;
var stats = data.stats;
for (var browserId in sampleStats) {
this.currentBrowser = browserId;
// Check if browser exists
if (!(browserId in stats)) {
this.throwError('No data found for browser "' + browserId + '"');
}
var sampleSupportByVersion = sampleStats[browserId];
var supportByVersion = stats[browserId];
for (var version in sampleSupportByVersion) {
this.currentVersion = version;
if (!(version in supportByVersion)) {
this.throwError('Browser version missing: ' + browserId + ' ' + version);
}
var support = supportByVersion[version];
this.currentVersion = null;
this.validateSupportValue(support);
}
this.validateKeys(browserId, sampleSupportByVersion, supportByVersion);
}
this.currentBrowser = null;
this.currentVersion = null;
this.validateKeys('stats', sampleStats, stats);
this.validateNoteCoherence();
};
this.validateFeature = function () {
this.validate('title', ['isString']);
this.validate('description', ['isString']);
this.validate('spec', ['isString', 'isURL']);
this.validate('status', ['isString', 'isStatus']);
this.validateStatusOfSpec();
this.validate('links', ['isArray', [{
url: ['isString', 'isURL'],
title: ['isString']
}]]);
this.validate('bugs', ['isArray', [{
description: ['isString']
}]]);
this.validate('categories', ['isArray', 'hasCategories']);
this.validate('notes', ['isString']);
this.validate('notes_by_num', ['isObject']);
this.validate('usage_perc_y', ['isNumber']);
this.validate('ucprefix', ['isBoolean']);
this.validate('parent', ['isString']);
this.validate('parent', function(featureid) {
// TODO: Check if existing feature
if (featureid === 'parentfeatureid' && id !== 'sample-data') {
this.throwError('"parent" value is invalid, got: ' + featureid);
}
}.bind(this));
this.validate('keywords', ['isString']);
this.validate('chrome_id', ['isString']);
this.validate('shown', ['isBoolean']);
this.validateSupportData();
};
this.validateUsage = function () {
this.validate('id', ['isString']);
this.validate('name', ['isString']);
this.validate('month', ['isString']);
this.validate('month', function(month) {
if (!/^\d{4}-\d{2}$/.test(month)) {
this.throwError('Format for month is invalid, got: ' + month);
}
}.bind(this));
this.validate('access_date', ['isString']);
this.validate('access_date', function(date) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
this.throwError('Format for access_date is invalid, got: ' + date);
}
}.bind(this));
this.validate('data', ['isObject']);
this.validate('total', ['isNumber']);
this.validate('total', function(total) {
// Total amount should be 100 - untracked usage
// If total is too low (especially for US) something is probably wrong
if (total < 80) {
this.warn('Expected total usage to be > 80, was ' + total);
}
if (id === 'US.json' && total < 95) {
this.warn('Expected US total usage to be > 95, was ' + total);
}
}.bind(this));
};
switch(type) {
case 'feature':
this.validateFeature();
break;
case 'usage':
this.validateUsage();
break;
}
};
var processFile = function (type, error, data, fileName) {
if (error) {
throw Error('Error: ' + error);
}
try {
data = JSON.parse(data);
} catch(e) {
throw Error('Error in file "' + fileName + '": ' + e);
}
if (type === 'feature') {
var matches = fileName.match(/([a-z0-9-]+)\.json$/);
if( !matches || matches.length < 2 ) {
console.log('Skipping file: ' + fileName);
return;
}
var featureId = matches[1];
new Validator('feature', featureId, data);
} else {
var id = /[^\/]+\.json/.exec(fileName)[0];
new Validator(type, id, data);
}
};
var readFile = function (file, type) {
fs.readFile(file, function (error, data) {
processFile(type, error, data, file);
});
};
fs.readFile(__dirname + '/../sample-data.json', function (error, data) {
if (error) {
throw Error('Error: ' + error);
}
sampleData = JSON.parse(data);
var files = fs.readdirSync(path);
for (var i = 0; i < files.length; i++) {
var file = files[i];
if (/[.]json$/.test(file)) {
readFile(path + '/' + file, 'feature');
} else {
throw Error('File "' + file + '" does not have ".json" extension');
}
}
});
var regionPath = __dirname + '/../region-usage-json';
var regionFiles = fs.readdirSync(regionPath);
for (var i = 0; i < regionFiles.length; i++) {
var file = regionFiles[i];
if (file.indexOf('.json') > -1) {
readFile(regionPath + '/' + file, 'usage');
}
}
}());