371 lines
14 KiB
JavaScript
371 lines
14 KiB
JavaScript
const nql = require('@tryghost/nql');
|
|
const _ = require('lodash');
|
|
const semver = require('semver');
|
|
const isEmail = require('validator/lib/isEmail');
|
|
const _private = {};
|
|
const packageJSONFileName = 'package.json';
|
|
const versions = require('../utils').versions;
|
|
|
|
const isSnakeCase = (str) => {
|
|
return /^[a-z0-9]+[a-z0-9_]*$/.test(str);
|
|
};
|
|
|
|
const v1PackageJSONConditionalRules = {
|
|
configPPIsInteger: 'GS010-PJ-CONF-PPP-INT'
|
|
};
|
|
const v1PackageJSONValidationRules = _.extend({
|
|
isPresent: 'GS010-PJ-REQ',
|
|
canBeParsed: 'GS010-PJ-PARSE',
|
|
nameIsRequired: 'GS010-PJ-NAME-REQ',
|
|
nameIsLowerCase: 'GS010-PJ-NAME-LC',
|
|
nameIsHyphenated: 'GS010-PJ-NAME-HY',
|
|
versionIsSemverCompliant: 'GS010-PJ-VERSION-SEM',
|
|
versionIsRequired: 'GS010-PJ-VERSION-REQ',
|
|
authorEmailIsValid: 'GS010-PJ-AUT-EM-VAL',
|
|
authorEmailIsRequired: 'GS010-PJ-AUT-EM-REQ',
|
|
configPPPIsRequired: 'GS010-PJ-CONF-PPP'
|
|
}, v1PackageJSONConditionalRules);
|
|
|
|
const v2PackageJSONConditionalRules = {};
|
|
|
|
const v2PackageJSONValidationRules = _.extend({},
|
|
{isMarkedGhostTheme: 'GS010-PJ-KEYWORDS'},
|
|
v2PackageJSONConditionalRules);
|
|
|
|
const v3PackageJSONConditionalRules = {};
|
|
const v3PackageJSONValidationRules = _.extend({},
|
|
{isNotPresentEngineGhostAPI: 'GS010-PJ-GHOST-API'},
|
|
{isv01EngineGhostAPI: 'GS010-PJ-GHOST-API-V01'},
|
|
v3PackageJSONConditionalRules
|
|
);
|
|
|
|
const v4PackageJSONConditionalRules = {};
|
|
const v4PackageJSONValidationRules = _.extend({},
|
|
{isv2EngineGhostAPI: 'GS010-PJ-GHOST-API-V2'},
|
|
{isPresentEngineGhostAPI: 'GS010-PJ-GHOST-API-PRESENT'},
|
|
{hasTooManyCustomThemeSettings: 'GS010-PJ-CUST-THEME-TOTAL-SETTINGS'},
|
|
{customThemeSettingsMustBeSnakecased: 'GS010-PJ-CUST-THEME-SETTINGS-CASE'},
|
|
{unknownCustomThemeSettingsType: 'GS010-PJ-CUST-THEME-SETTINGS-TYPE'},
|
|
{unknownCustomThemeSettingsGroup: 'GS010-PJ-CUST-THEME-SETTINGS-GROUP'},
|
|
{missingCustomThemeSettingsSelectOptions: 'GS010-PJ-CUST-THEME-SETTINGS-SELECT-OPTIONS'},
|
|
{missingCustomThemeSettingsSelectDefault: 'GS010-PJ-CUST-THEME-SETTINGS-SELECT-DEFAULT'},
|
|
{invalidCustomThemeSetingBooleanDefault: 'GS010-PJ-CUST-THEME-SETTINGS-BOOLEAN-DEFAULT'},
|
|
{invalidCustomThemeSetingColorDefault: 'GS010-PJ-CUST-THEME-SETTINGS-COLOR-DEFAULT'},
|
|
{invalidCustomThemeSetingImageDefault: 'GS010-PJ-CUST-THEME-SETTINGS-IMAGE-DEFAULT'},
|
|
v4PackageJSONConditionalRules
|
|
);
|
|
|
|
const canaryPackageJSONConditionalRules = {
|
|
invalidCustomThemeSettingVisibilitySyntax: 'GS010-PJ-CUST-THEME-SETTINGS-VISIBILITY-SYNTAX',
|
|
invalidCustomThemeSettingVisibilityValue: 'GS010-PJ-CUST-THEME-SETTINGS-VISIBILITY-VALUE'
|
|
};
|
|
const canaryPackageJSONValidationRules = _.extend({},
|
|
{isNotPresentCardAssets: 'GS010-PJ-GHOST-CARD-ASSETS-NOT-PRESENT'},
|
|
{invalidCustomThemeSettingDescriptionLength: 'GS010-PJ-CUST-THEME-SETTINGS-DESCRIPTION-LENGTH'},
|
|
canaryPackageJSONConditionalRules
|
|
);
|
|
|
|
_private.validatePackageJSONFields = function validatePackageJSONFields(packageJSON, theme, packageJSONValidationRules) {
|
|
let failed = [];
|
|
const passedRulesToOmit = [];
|
|
|
|
// Only add this rule to the failed list if it's defined in packageJSONValidationRules
|
|
function markFailed(rule) {
|
|
if (packageJSONValidationRules[rule]) {
|
|
failed.push(packageJSONValidationRules[rule]);
|
|
}
|
|
}
|
|
|
|
if (!packageJSON.name) {
|
|
markFailed('nameIsRequired');
|
|
passedRulesToOmit.push('nameIsLowerCase');
|
|
passedRulesToOmit.push('nameIsHyphenated');
|
|
}
|
|
|
|
if (!packageJSON.version) {
|
|
markFailed('versionIsRequired');
|
|
passedRulesToOmit.push('versionIsSemverCompliant');
|
|
}
|
|
|
|
if (!packageJSON.config || _.isNil(packageJSON.config.posts_per_page)) {
|
|
markFailed('configPPPIsRequired');
|
|
passedRulesToOmit.push('configPPIsInteger');
|
|
} else if (!_.isNumber(packageJSON.config.posts_per_page) || packageJSON.config.posts_per_page < 1) {
|
|
failed = _.without(failed, packageJSONValidationRules.configPPPIsRequired);
|
|
markFailed('configPPIsInteger');
|
|
}
|
|
|
|
if (!packageJSON.author || !packageJSON.author.email) {
|
|
markFailed('authorEmailIsRequired');
|
|
passedRulesToOmit.push('authorEmailIsValid');
|
|
}
|
|
|
|
if (!packageJSON.keywords || !_.isArray(packageJSON.keywords) || !_.includes(packageJSON.keywords, 'ghost-theme')) {
|
|
markFailed('isMarkedGhostTheme');
|
|
}
|
|
|
|
if (packageJSON.name && packageJSON.name !== packageJSON.name.toLowerCase()) {
|
|
markFailed('nameIsLowerCase');
|
|
}
|
|
|
|
if (packageJSON.name && !packageJSON.name.match(/^([a-z0-9]+-)*[a-z0-9]+$/gi)) {
|
|
markFailed('nameIsHyphenated');
|
|
}
|
|
|
|
if (packageJSON.version && !semver.valid(packageJSON.version)) {
|
|
markFailed('versionIsSemverCompliant');
|
|
}
|
|
|
|
if (packageJSON.author && packageJSON.author.email && !isEmail(packageJSON.author.email)) {
|
|
markFailed('authorEmailIsValid');
|
|
}
|
|
|
|
if (!packageJSON.engines || !packageJSON.engines['ghost-api']) {
|
|
markFailed('isNotPresentEngineGhostAPI');
|
|
}
|
|
|
|
if (packageJSON.engines && packageJSON.engines['ghost-api']) {
|
|
markFailed('isPresentEngineGhostAPI');
|
|
}
|
|
|
|
if (packageJSON.engines && packageJSON.engines['ghost-api']) {
|
|
// NOTE: checks for same versions as were available in Ghost 2.0 (v0.1, ^0.1 etc.)
|
|
// ref.: https://github.com/TryGhost/Ghost/blob/bc41550/core/frontend/services/themes/engines/create.js#L10-L16
|
|
const coerced = semver.coerce(packageJSON.engines['ghost-api']);
|
|
|
|
if (coerced !== null) {
|
|
const major = semver.major(coerced);
|
|
|
|
if (major === 0) {
|
|
markFailed('isv01EngineGhostAPI');
|
|
}
|
|
|
|
if (major === 2) {
|
|
markFailed('isv2EngineGhostAPI');
|
|
}
|
|
}
|
|
}
|
|
|
|
// @TODO: validate that the card_assets config is valid
|
|
if (!packageJSON.config || (_.isEmpty(packageJSON.config.card_assets) && !_.isBoolean(packageJSON.config.card_assets))) {
|
|
markFailed('isNotPresentCardAssets');
|
|
}
|
|
|
|
if (packageJSON.config && packageJSON.config.custom) {
|
|
const customSettingsKeys = Object.keys(packageJSON.config.custom);
|
|
|
|
if (customSettingsKeys.length > 20) {
|
|
markFailed('hasTooManyCustomThemeSettings');
|
|
}
|
|
|
|
if (customSettingsKeys.some(key => !isSnakeCase(key))) {
|
|
markFailed('customThemeSettingsMustBeSnakecased');
|
|
}
|
|
|
|
const knownSettingsTypes = new Set(['select', 'boolean', 'color', 'image', 'text']);
|
|
if (customSettingsKeys.some(key => !knownSettingsTypes.has(packageJSON.config.custom[key].type))) {
|
|
markFailed('unknownCustomThemeSettingsType');
|
|
}
|
|
|
|
const knownSettingsGroups = new Set(['post', 'homepage']);
|
|
// Ignore undefined values as "groups" is an optional property
|
|
if (customSettingsKeys.some(key => typeof packageJSON.config.custom[key].group !== 'undefined'
|
|
&& !knownSettingsGroups.has(packageJSON.config.custom[key].group))) {
|
|
markFailed('unknownCustomThemeSettingsGroup');
|
|
}
|
|
|
|
for (const key of customSettingsKeys) {
|
|
const entry = packageJSON.config.custom[key];
|
|
switch (entry.type) {
|
|
case 'select':
|
|
if (!Array.isArray(entry.options) || entry.options.length < 2) {
|
|
markFailed('missingCustomThemeSettingsSelectOptions');
|
|
} else if (!entry.options.find(option => option === entry.default)) {
|
|
markFailed('missingCustomThemeSettingsSelectDefault');
|
|
}
|
|
break;
|
|
case 'boolean':
|
|
if (![true, false].includes(entry.default)) {
|
|
markFailed('invalidCustomThemeSetingBooleanDefault');
|
|
}
|
|
break;
|
|
case 'color':
|
|
if (!/^#[0-9a-f]{6}$/i.test(entry.default)) {
|
|
markFailed('invalidCustomThemeSetingColorDefault');
|
|
}
|
|
break;
|
|
case 'image':
|
|
if (entry.default) {
|
|
markFailed('invalidCustomThemeSetingImageDefault');
|
|
}
|
|
break;
|
|
default:
|
|
//do nothing here
|
|
}
|
|
|
|
if (entry.description && entry.description.length > 100) {
|
|
markFailed('invalidCustomThemeSettingDescriptionLength');
|
|
}
|
|
|
|
if (entry.visibility) {
|
|
// Validate that the provided visibility value is valid nql
|
|
try {
|
|
nql(entry.visibility).parse();
|
|
} catch (err) {
|
|
markFailed('invalidCustomThemeSettingVisibilitySyntax');
|
|
}
|
|
|
|
// Validate that the provided visibility value only references known custom settings
|
|
const customSettingKeys = Object.keys(packageJSON.config.custom);
|
|
const referencedKeys = entry.visibility.match(/[a-zA-Z_][a-zA-Z0-9_.]+:/).map(k => k.slice(0, -1));
|
|
const unknownKeys = referencedKeys.filter(k => !customSettingKeys.includes(k));
|
|
|
|
if (unknownKeys.length > 0) {
|
|
markFailed('invalidCustomThemeSettingVisibilityValue');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const failedRules = _private.getFailedRules(failed);
|
|
_.each(failedRules, (rule, key) => {
|
|
theme.results.fail[key] = {};
|
|
theme.results.fail[key].failures = [
|
|
{
|
|
ref: 'package.json'
|
|
}
|
|
];
|
|
});
|
|
|
|
// add intersection as passed
|
|
theme.results.pass = theme.results.pass.concat(_.xor(failed, _.values(_.omit(packageJSONValidationRules, passedRulesToOmit))));
|
|
|
|
return theme;
|
|
};
|
|
|
|
/**
|
|
* Sucks!
|
|
* The current implementation expects:
|
|
*
|
|
* { 'rule-code': {} }
|
|
*/
|
|
_private.getFailedRules = function getFailedRules(keys) {
|
|
return _.zipObject(keys, _.map(keys, function () {
|
|
return {};
|
|
}));
|
|
};
|
|
|
|
module.exports = function checkPackageJSON(theme, options) {
|
|
const checkVersion = _.get(options, 'checkVersion', versions.default);
|
|
let packageJSONValidationRules;
|
|
let packageJSONConditionalRules;
|
|
|
|
if (checkVersion === 'v1') {
|
|
packageJSONValidationRules = v1PackageJSONValidationRules;
|
|
packageJSONConditionalRules = v1PackageJSONConditionalRules;
|
|
} else if (checkVersion === 'v2') {
|
|
packageJSONValidationRules = _.merge(
|
|
{},
|
|
v1PackageJSONValidationRules,
|
|
v2PackageJSONValidationRules
|
|
);
|
|
packageJSONConditionalRules = _.merge(
|
|
{},
|
|
v1PackageJSONConditionalRules,
|
|
v2PackageJSONConditionalRules
|
|
);
|
|
} else if (checkVersion === 'v3') {
|
|
packageJSONValidationRules = _.merge(
|
|
{},
|
|
v1PackageJSONValidationRules,
|
|
v2PackageJSONValidationRules,
|
|
v3PackageJSONValidationRules
|
|
);
|
|
packageJSONConditionalRules = _.merge(
|
|
{},
|
|
v1PackageJSONConditionalRules,
|
|
v2PackageJSONConditionalRules,
|
|
v3PackageJSONConditionalRules
|
|
);
|
|
} else if (checkVersion === 'v5') {
|
|
packageJSONValidationRules = _.merge(
|
|
{},
|
|
v1PackageJSONValidationRules,
|
|
v2PackageJSONValidationRules,
|
|
v3PackageJSONValidationRules,
|
|
v4PackageJSONValidationRules,
|
|
canaryPackageJSONValidationRules
|
|
);
|
|
// Make these delete more declarative as the list grows
|
|
delete packageJSONValidationRules.isNotPresentEngineGhostAPI;
|
|
delete packageJSONValidationRules.isv01EngineGhostAPI;
|
|
delete packageJSONValidationRules.isv2EngineGhostAPI;
|
|
|
|
packageJSONConditionalRules = _.merge(
|
|
{},
|
|
v1PackageJSONConditionalRules,
|
|
v2PackageJSONConditionalRules,
|
|
v3PackageJSONConditionalRules,
|
|
v4PackageJSONConditionalRules,
|
|
canaryPackageJSONConditionalRules
|
|
);
|
|
} else {
|
|
// default check for current version 'v4' rules
|
|
packageJSONValidationRules = _.merge(
|
|
{},
|
|
v1PackageJSONValidationRules,
|
|
v2PackageJSONValidationRules,
|
|
v3PackageJSONValidationRules,
|
|
v4PackageJSONValidationRules
|
|
);
|
|
delete packageJSONValidationRules.isNotPresentEngineGhostAPI;
|
|
|
|
packageJSONConditionalRules = _.merge(
|
|
{},
|
|
v1PackageJSONConditionalRules,
|
|
v2PackageJSONConditionalRules,
|
|
v3PackageJSONConditionalRules,
|
|
v4PackageJSONConditionalRules
|
|
);
|
|
}
|
|
|
|
let [packageJSON] = _.filter(theme.files, {file: packageJSONFileName});
|
|
|
|
// CASE: package.json must be valid
|
|
if (packageJSON && packageJSON.content) {
|
|
try {
|
|
let packageJSONParsed = JSON.parse(packageJSON.content);
|
|
theme = _private.validatePackageJSONFields(packageJSONParsed, theme, packageJSONValidationRules);
|
|
|
|
theme.name = packageJSONParsed.name;
|
|
theme.version = packageJSONParsed.version;
|
|
return theme;
|
|
} catch (err) {
|
|
_.extend(theme.results.fail, _private.getFailedRules(_.values(_.omit(packageJSONValidationRules, ['isPresent'].concat(_.keys(packageJSONConditionalRules))))));
|
|
|
|
theme.results.fail[packageJSONValidationRules.canBeParsed].failures = [
|
|
{
|
|
ref: 'package.json',
|
|
message: err.message
|
|
}
|
|
];
|
|
|
|
return theme;
|
|
}
|
|
} else {
|
|
// CASE: package.json must be present (if not, all validation rules fail)
|
|
const rules = _private.getFailedRules(_.values(_.omit(packageJSONValidationRules, _.keys(packageJSONConditionalRules))));
|
|
|
|
_.each(rules, (rule, key) => {
|
|
theme.results.fail[key] = {};
|
|
theme.results.fail[key].failures = [
|
|
{
|
|
ref: packageJSONFileName
|
|
}
|
|
];
|
|
});
|
|
|
|
return theme;
|
|
}
|
|
};
|