diff --git a/includes/common/ConfigGenerator.js b/includes/common/ConfigGenerator.js new file mode 100644 index 0000000..189d695 --- /dev/null +++ b/includes/common/ConfigGenerator.js @@ -0,0 +1,84 @@ +const yaml = require('js-yaml'); +const Type = require('js-yaml/lib/js-yaml/type'); +const Schema = require('js-yaml/lib/js-yaml/schema'); + +const { is, descriptors } = require('./utils'); +const { doc, type, requires, defaultValue } = descriptors; + +const UNDEFINED = Symbol('undefined'); +// output null as empty in yaml +const YAML_SCHEMA = new Schema({ + include: [ + require('js-yaml/lib/js-yaml/schema/default_full') + ], + implicit: [ + new Type('tag:yaml.org,2002:null', { + kind: 'scalar', + resolve(data) { + if (data === null) { + return true; + } + const max = data.length; + return (max === 1 && data === '~') || + (max === 4 && (data === 'null' || data === 'Null' || data === 'NULL')); + }, + construct: () => null, + predicate: object => object === null, + represent: { + empty: function () { return ''; } + }, + defaultStyle: 'empty' + }) + ] +}); + +function generate(spec, parentConfig = null) { + if (!is.spec(spec)) { + return UNDEFINED; + } + if (spec.hasOwnProperty(requires) && !spec[requires](parentConfig)) { + return UNDEFINED; + } + if (spec.hasOwnProperty(defaultValue)) { + return spec[defaultValue]; + } + const types = is.array(spec[type]) ? spec[type] : [spec[type]]; + if (types.includes('object')) { + let defaults = UNDEFINED; + for (let key in spec) { + if (key === '*') { + continue; + } + const value = generate(spec[key], defaults); + if (value !== UNDEFINED) { + if (defaults === UNDEFINED) { + defaults = {}; + } + if (spec[key].hasOwnProperty(doc)) { + defaults['#' + key] = spec[key][doc]; + } + defaults[key] = value; + } + } + return defaults; + } else if (types.includes('array') && spec.hasOwnProperty('*')) { + return [generate(spec['*'], {})]; + } + return UNDEFINED; +} + +class ConfigGenerator { + constructor(spec) { + this.spec = spec; + } + + generate() { + return yaml.safeDump(generate(this.spec), { + indent: 4, + lineWidth: 1024, + schema: YAML_SCHEMA + }).replace(/^(\s*)\'#.*?\':\s*\'*(.*?)\'*$/mg, '$1# $2'); // make comment lines + } +} + +module.exports = ConfigGenerator; diff --git a/includes/common/ConfigValidator.js b/includes/common/ConfigValidator.js new file mode 100644 index 0000000..f59440f --- /dev/null +++ b/includes/common/ConfigValidator.js @@ -0,0 +1,132 @@ +const { is, descriptors } = require('./utils'); +const { type, required, requires, format, defaultValue } = descriptors; +const { + InvalidSpecError, + MissingRequiredError, + TypeMismatchError, + FormatMismatchError, + VersionMalformedError, + VersionNotFoundError, + VersionMismatchError } = require('./utils').errors; + +function isRequiresSatisfied(spec, config) { + try { + if (!spec.hasOwnProperty(requires) || spec[requires](config) === true) { + return true; + } + } catch (e) { } + return false; +} + +function getConfigType(spec, config) { + const specTypes = is.array(spec[type]) ? spec[type] : [spec[type]]; + for (let specType of specTypes) { + if (is[specType](config)) { + return specType; + } + } + return null; +} + +function hasFormat(spec, config) { + if (!spec.hasOwnProperty(format)) { + return true; + } + return spec[format].test(config); +} + +function validate(spec, config, parentConfig, path) { + if (!is.spec(spec)) { + throw new InvalidSpecError(spec, path); + } + if (!isRequiresSatisfied(spec, parentConfig)) { + return; + } + if (is.undefined(config) || is.null(config)) { + if (spec[required] === true) { + throw new MissingRequiredError(spec, path); + } + return; + } + const type = getConfigType(spec, config); + if (type === null) { + throw new TypeMismatchError(spec, path, config); + } + if (type === 'string') { + if (!hasFormat(spec, config)) { + throw new FormatMismatchError(spec, path, config); + } + } else if (type === 'array' && spec.hasOwnProperty('*')) { + config.forEach((child, i) => validate(spec['*'], child, config, path.concat(`[${i}]`))); + } else if (type === 'object') { + for (let key in spec) { + if (key === '*') { + Object.keys(config).forEach(k => validate(spec['*'], config[k], config, path.concat(k))); + } else { + validate(spec[key], config[key], config, path.concat(key)); + } + } + } +} + +function formatVersion(ver) { + const m = /^(\d)+\.(\d)+\.(\d)+(?:-([0-9A-Za-z-]+))*$/.exec(ver); + if (m === null) { + throw new VersionMalformedError(ver); + } + return { + major: m[1], + minor: m[2], + patch: m[3], + identifier: m.length > 4 ? m[4] : null + }; +} + +function compareVersion(ver1, ver2) { + for (let section of ['major', 'minor', 'patch']) { + if (ver1[section] !== ver2[section]) { + return Math.sign(ver1[section] - ver2[section]); + } + } + const id1 = ver1.hasOwnProperty('identifier') ? ver1.identifier : null; + const id2 = ver2.hasOwnProperty('identifier') ? ver2.identifier : null; + if (id1 === id2) { + return 0; + } + if (id1 === null) { + return 1; + } + if (id2 === null) { + return -1; + } + return id1.localeCompare(id2); +} + +function isBreakingChange(base, ver) { + return base.major !== ver.major; +} + + +function checkVersion(spec, config) { + if (!config.hasOwnProperty('version')) { + throw new VersionNotFoundError(); + } + const configVersion = formatVersion(config.version); + const specVersion = formatVersion(spec.version[defaultValue]); + if (isBreakingChange(specVersion, configVersion)) { + throw new VersionMismatchError(spec.version[defaultValue], config.version, compareVersion(specVersion, configVersion) > 0); + } +} + +class ConfigValidator { + constructor(spec) { + this.spec = spec; + } + + validate(config) { + checkVersion(this.spec, config); + validate(this.spec, config, null, []); + } +} + +module.exports = ConfigValidator; \ No newline at end of file diff --git a/includes/common/utils.js b/includes/common/utils.js new file mode 100644 index 0000000..882fccc --- /dev/null +++ b/includes/common/utils.js @@ -0,0 +1,148 @@ +const doc = Symbol('@doc'); +const type = Symbol('@type'); +const format = Symbol('@format'); +const required = Symbol('@required'); +const requires = Symbol('@requires'); +const defaultValue = Symbol('@default'); + +const descriptors = { + doc, + type, + format, + requires, + required, + defaultValue +}; + +const is = (() => ({ + string(value) { + return typeof (value) === 'string'; + }, + array(value) { + return Array.isArray(value); + }, + boolean(value) { + return typeof (value) === 'boolean'; + }, + object(value) { + return typeof (value) === 'object' && value.constructor == Object; + }, + function(value) { + return typeof (value) === 'function'; + }, + regexp(value) { + return value instanceof RegExp; + }, + undefined(value) { + return typeof (value) === 'undefined'; + }, + null(value) { + return value === null; + }, + spec(value) { + if (!value.hasOwnProperty(type)) { + return false; + } + if (!is.string(value[type]) && !is.array(value[type])) { + return false; + } + if (value.hasOwnProperty(doc) && !is.string(value[doc])) { + return false; + } + if (value.hasOwnProperty(required) && !is.boolean(value[required])) { + return false; + } + if (value.hasOwnProperty(requires) && !is.function(value[requires])) { + return false; + } + if (value.hasOwnProperty(format) && !is.regexp(value[format])) { + return false; + } + return true; + } +}))(); + +class ConfigError extends Error { + constructor(spec, path) { + super(); + this.spec = spec; + this.path = path; + } +} + +class InvalidSpecError extends ConfigError { + constructor(spec, path) { + super(spec, path); + this.message = `The specification '${path.join('.')}' is invalid.`; + } +} + +class MissingRequiredError extends ConfigError { + constructor(spec, path) { + super(spec, path); + this.message = `Configuration file do not have the required '${path.join('.')}' field.`; + } +} + +class TypeMismatchError extends ConfigError { + constructor(spec, path, config) { + super(spec, path); + this.config = config; + this.message = `Configuration '${path.join('.')}' is not one of the '${spec[type]}' type.`; + } +} + +class FormatMismatchError extends ConfigError { + constructor(spec, path, config) { + super(spec, path); + this.config = config; + this.message = `Configuration '${path.join('.')}' do not match the format '${spec[format]}'.`; + } +} + +class VersionError extends Error { +} + +class VersionNotFoundError extends VersionError { + constructor() { + super(`Version number is not found in the configuration file.`); + } +} + +class VersionMalformedError extends VersionError { + constructor(version) { + super(`Version number ${version} is malformed.`); + this.version = version; + } +} + +class VersionMismatchError extends VersionError { + constructor(specVersion, configVersion, isConfigVersionSmaller) { + super(); + this.specVersion = specVersion; + this.configVersion = configVersion; + if (isConfigVersionSmaller) { + this.message = `The configuration version ${configVersion} is far behind the specification version ${specVersion}.`; + } else { + this.message = `The configuration version ${configVersion} is way ahead of the specification version ${specVersion}.`; + } + } +} + +const errors = { + ConfigError, + InvalidSpecError, + MissingRequiredError, + TypeMismatchError, + FormatMismatchError, + VersionError, + VersionMalformedError, + VersionNotFoundError, + VersionMismatchError +} + +module.exports = { + is, + descriptors, + errors +}; \ No newline at end of file diff --git a/includes/helpers/config.js b/includes/helpers/config.js index c0f1839..d29bcd7 100644 --- a/includes/helpers/config.js +++ b/includes/helpers/config.js @@ -7,8 +7,8 @@ * <%- has_config(config_name, exclude_page) %> * <%- get_config(config_name, default_value, exclude_page) %> */ -const specs = require('../specs/_config.yml'); -const descriptors = require('../specs/common').descriptor; +const specs = require('../specs/config.spec'); +const descriptors = require('../common/utils').descriptors; module.exports = function (hexo) { function readProperty(object, path) { diff --git a/includes/specs/_config.yml.js b/includes/specs/_config.yml.js deleted file mode 100644 index 421d11e..0000000 --- a/includes/specs/_config.yml.js +++ /dev/null @@ -1,473 +0,0 @@ -const { version } = require('../../package.json'); -const { type, required, defaultValue, description, condition } = require('./common').descriptor; -const desc = description; - -const IconLink = { - [type]: 'object', - [desc]: 'Link icon settings', - '*': { - [type]: ['string', 'object'], - [desc]: 'Path or URL to the menu item, and/or link icon class names', - icon: { - [required]: true, - [type]: 'string', - [desc]: 'Link icon class names' - }, - url: { - [required]: true, - [type]: 'string', - [desc]: 'Path or URL to the menu item' - } - } -}; - -const DEFAULT_WIDGETS = [ - { - type: 'profile', - position: 'left', - author: 'Your name', - author_title: 'Your title', - location: 'Your location', - avatar: null, - gravatar: null, - follow_link: 'http://github.com/ppoffice', - social_links: { - Github: { - icon: 'fab fa-github', - url: 'http://github.com/ppoffice' - }, - Facebook: { - icon: 'fab fa-facebook', - url: 'http://facebook.com' - }, - Twitter: { - icon: 'fab fa-twitter', - url: 'http://twitter.com' - }, - Dribbble: { - icon: 'fab fa-dribbble', - url: 'http://dribbble.com' - }, - RSS: { - icon: 'fas fa-rss', - url: '/' - } - } - }, - { - type: 'toc', - position: 'left' - }, - { - type: 'links', - position: 'left', - links: { - Hexo: 'https://hexo.io', - Github: 'https://github.com/ppoffice' - } - }, - { - type: 'category', - position: 'left' - }, - { - type: 'tagcloud', - position: 'left' - }, - { - type: 'recent_posts', - position: 'right' - }, - { - type: 'archive', - position: 'right' - }, - { - type: 'tag', - position: 'right' - } -]; - -module.exports = { - [type]: 'object', - [desc]: 'Root of the configuration file', - [required]: true, - version: { - [type]: 'string', - [desc]: 'Version of the Icarus theme that is currently used', - [required]: true, - [defaultValue]: version - }, - favicon: { - [type]: 'string', - [desc]: 'Path or URL to the website\'s icon', - [defaultValue]: null - }, - open_graph: { - [type]: 'object', - [desc]: 'Open Graph metadata (https://hexo.io/docs/helpers.html#open-graph)', - fb_app_id: { - [type]: 'string', - [desc]: 'Facebook App ID', - [defaultValue]: null - }, - fb_admins: { - [type]: 'string', - [desc]: 'Facebook Admin ID', - [defaultValue]: null - }, - twitter_id: { - [type]: 'string', - [desc]: 'Twitter ID', - [defaultValue]: null - }, - twitter_site: { - [type]: 'string', - [desc]: 'Twitter site', - [defaultValue]: null - }, - google_plus: { - [type]: 'string', - [desc]: 'Google+ profile link', - [defaultValue]: null - } - }, - rss: { - [type]: 'string', - [desc]: 'Path or URL to RSS atom.xml', - [defaultValue]: null - }, - logo: { - [type]: ['string', 'object'], - [defaultValue]: '/images/logo.svg', - [desc]: 'Path or URL to the website\'s logo to be shown on the left of the navigation bar or footer', - text: { - [type]: 'string', - [desc]: 'Text to be shown in place of the logo image' - } - }, - navbar: { - [type]: 'object', - [desc]: 'Navigation bar link settings', - menu: { - [type]: 'object', - [desc]: 'Navigation bar menu links', - [defaultValue]: { - Home: '/', - Archives: '/archives', - Categories: '/categories', - Tags: '/tags', - About: '/about' - }, - '*': { - [type]: 'string', - [desc]: 'Path or URL to the menu item' - } - }, - links: { - ...IconLink, - [desc]: 'Navigation bar links to be shown on the right', - [defaultValue]: { - 'Download on GitHub': { - icon: 'fab fa-github', - url: 'http://github.com/ppoffice/hexo-theme-icarus' - } - } - } - }, - footer: { - [type]: 'object', - [description]: 'Footer section link settings', - links: { - ...IconLink, - [desc]: 'Links to be shown on the right of the footer section', - [defaultValue]: { - 'Creative Commons': { - icon: 'fab fa-creative-commons', - url: 'https://creativecommons.org/' - }, - 'Attribution 4.0 International': { - icon: 'fab fa-creative-commons-by', - url: 'https://creativecommons.org/licenses/by/4.0/' - }, - 'Download on GitHub': { - icon: 'fab fa-github', - url: 'http://github.com/ppoffice/hexo-theme-icarus' - } - } - } - }, - article: { - [type]: 'object', - [desc]: 'Article display settings', - highlight: { - [type]: 'string', - [desc]: 'Code highlight theme (https://github.com/highlightjs/highlight.js/tree/master/src/styles)', - [defaultValue]: 'atom-one-light' - }, - thumbnail: { - [type]: 'boolean', - [desc]: 'Whether to show article thumbnail images', - [defaultValue]: true - }, - readtime: { - [type]: 'boolean', - [desc]: 'Whether to show estimate article reading time', - [defaultValue]: true - } - }, - search: { - [type]: 'object', - [desc]: 'Search plugin settings (http://ppoffice.github.io/hexo-theme-icarus/categories/Configuration/Search-Plugins)', - type: { - [type]: 'string', - [desc]: 'Name of the search plugin', - [defaultValue]: 'insight' - }, - cx: { - [type]: 'string', - [desc]: 'Google CSE cx value', - [required]: true, - [condition]: parent => parent.type === 'google-cse' - } - }, - comment: { - [type]: 'object', - [desc]: 'Comment plugin settings (http://ppoffice.github.io/hexo-theme-icarus/categories/Configuration/Comment-Plugins)', - type: { - [type]: 'string', - [desc]: 'Name of the comment plugin', - [defaultValue]: null - }, - appid: { - [type]: 'string', - [desc]: 'Changyan comment app ID', - [required]: true, - [condition]: parent => parent.type === 'changyan' - }, - conf: { - [type]: 'string', - [desc]: 'Changyan comment configuration ID', - [required]: true, - [condition]: parent => parent.type === 'changyan' - }, - shortname: { - [type]: 'string', - [desc]: 'Disqus shortname', - [required]: true, - [condition]: parent => parent.type === 'disqus' - }, - owner: { - [type]: 'string', - [desc]: 'Your GitHub ID', - [required]: true, - [condition]: parent => parent.type === 'gitment' - }, - repo: { - [type]: 'string', - [desc]: 'The repo to store comments', - [required]: true, - [condition]: parent => parent.type === 'gitment' - }, - client_id: { - [type]: 'string', - [desc]: 'Your client ID', - [required]: true, - [condition]: parent => parent.type === 'gitment' - }, - client_secret: { - [type]: 'string', - [desc]: 'Your client secret', - [required]: true, - [condition]: parent => parent.type === 'gitment' - }, - url: { - [type]: 'string', - [desc]: 'URL to your Isso comment service', - [required]: true, - [condition]: parent => parent.type === 'isso' - }, - uid: { - [type]: 'string', - [desc]: 'LiveRe comment service UID', - [required]: true, - [condition]: parent => parent.type === 'livere' - }, - app_id: { - [type]: 'boolean', - [desc]: 'LeanCloud APP ID', - [required]: true, - [condition]: parent => parent.type === 'valine' - }, - app_key: { - [type]: 'boolean', - [desc]: 'LeanCloud APP key', - [required]: true, - [condition]: parent => parent.type === 'valine' - }, - notify: { - [type]: 'boolean', - [desc]: 'Enable email notification when someone comments', - [defaultValue]: false, - [condition]: parent => parent.type === 'valine' - }, - verify: { - [type]: 'boolean', - [desc]: 'Enable verification code service', - [defaultValue]: false, - [condition]: parent => parent.type === 'valine' - }, - placeholder: { - [type]: 'boolean', - [desc]: 'Placeholder text in the comment box', - [defaultValue]: 'Say something...', - [condition]: parent => parent.type === 'valine' - } - }, - share: { - [type]: 'object', - [desc]: 'Share plugin settings (http://ppoffice.github.io/hexo-theme-icarus/categories/Configuration/Share-Plugins)', - type: { - [type]: 'string', - [desc]: 'Share plugin name', - [defaultValue]: null - }, - install_url: { - [type]: 'string', - [desc]: 'URL to the share plugin script provided by share plugin service provider', - [required]: true, - [condition]: parent => parent.type === 'sharethis' || parent.type === 'addthis' - } - }, - widgets: { - [type]: 'array', - [desc]: 'Sidebar widget settings', - [defaultValue]: DEFAULT_WIDGETS, - '*': { - [type]: 'object', - [desc]: 'Single widget settings', - type: { - [type]: 'string', - [desc]: 'Widget name', - [required]: true, - [defaultValue]: 'profile' - }, - position: { - [type]: 'string', - [desc]: 'Where should the widget be placed, left or right', - [required]: true, - [defaultValue]: 'left' - }, - author: { - [type]: 'string', - [desc]: 'Author name to be shown in the profile widget', - [condition]: parent => parent.type === 'profile', - [defaultValue]: 'Your name' - }, - author_title: { - [type]: 'string', - [desc]: 'Title of the author to be shown in the profile widget', - [condition]: parent => parent.type === 'profile', - [defaultValue]: 'Your title' - }, - location: { - [type]: 'string', - [desc]: 'Author\'s current location to be shown in the profile widget', - [condition]: parent => parent.type === 'profile', - [defaultValue]: 'Your location' - }, - avatar: { - [type]: 'string', - [desc]: 'Path or URL to the avatar to be shown in the profile widget', - [condition]: parent => parent.type === 'profile', - [defaultValue]: '/images/avatar.png' - }, - gravatar: { - [type]: 'string', - [desc]: 'Email address for the Gravatar to be shown in the profile widget', - [condition]: parent => parent.type === 'profile' - }, - follow_link: { - [type]: 'string', - [desc]: 'Path or URL for the follow button', - [condition]: parent => parent.type === 'profile' - }, - social_links: { - ...IconLink, - [desc]: 'Links to be shown on the bottom of the profile widget', - [condition]: parent => parent.type === 'profile' - }, - links: { - [type]: 'object', - [desc]: 'Links to be shown in the links widget', - [condition]: parent => parent.type === 'links', - '*': { - [type]: 'string', - [desc]: 'Path or URL to the link', - [required]: true - } - } - } - }, - plugins: { - [type]: 'object', - [desc]: 'Other plugin settings', - gallery: { - [type]: 'boolean', - [desc]: 'Enable the lightGallery and Justified Gallery plugins', - [defaultValue]: true - }, - 'outdated-browser': { - [type]: 'boolean', - [desc]: 'Enable the Outdated Browser plugin', - [defaultValue]: true - }, - animejs: { - [type]: 'boolean', - [desc]: 'Enable page animations', - [defaultValue]: true - }, - mathjax: { - [type]: 'boolean', - [desc]: 'Enable the MathJax plugin', - [defaultValue]: true - }, - 'google-analytics': { - [type]: ['boolean', 'object'], - [desc]: 'Google Analytics plugin settings (http://ppoffice.github.io/hexo-theme-icarus/2018/01/01/plugin/Analytics/#Google-Analytics)', - tracking_id: { - [type]: 'string', - [desc]: 'Google Analytics tracking id', - [defaultValue]: null - } - }, - 'baidu-analytics': { - [type]: ['boolean', 'object'], - [desc]: 'Baidu Analytics plugin settings (http://ppoffice.github.io/hexo-theme-icarus/2018/01/01/plugin/Analytics/#Baidu-Analytics)', - tracking_id: { - [type]: 'string', - [desc]: 'Baidu Analytics tracking id', - [defaultValue]: null - } - } - }, - providers: { - [type]: 'object', - [desc]: 'CDN provider settings', - cdn: { - [type]: 'string', - [desc]: 'Name or URL of the JavaScript and/or stylesheet CDN provider', - [defaultValue]: 'cdnjs' - }, - fontcdn: { - [type]: 'string', - [desc]: 'Name or URL of the webfont CDN provider', - [defaultValue]: 'google' - }, - iconcdn: { - [type]: 'string', - [desc]: 'Name or URL of the webfont Icon CDN provider', - [defaultValue]: 'fontawesome' - } - } -}; \ No newline at end of file diff --git a/includes/specs/article.spec.js b/includes/specs/article.spec.js new file mode 100644 index 0000000..d6793e1 --- /dev/null +++ b/includes/specs/article.spec.js @@ -0,0 +1,21 @@ +const { doc, type, defaultValue } = require('../common/utils').descriptors; + +module.exports = { + [type]: 'object', + [doc]: 'Article display settings', + highlight: { + [type]: 'string', + [doc]: 'Code highlight theme (https://github.com/highlightjs/highlight.js/tree/master/src/styles)', + [defaultValue]: 'atom-one-light' + }, + thumbnail: { + [type]: 'boolean', + [doc]: 'Whether to show article thumbnail images', + [defaultValue]: true + }, + readtime: { + [type]: 'boolean', + [doc]: 'Whether to show estimate article reading time', + [defaultValue]: true + } +}; \ No newline at end of file diff --git a/includes/specs/comment.spec.js b/includes/specs/comment.spec.js new file mode 100644 index 0000000..76b732d --- /dev/null +++ b/includes/specs/comment.spec.js @@ -0,0 +1,119 @@ +const { doc, type, defaultValue, required, requires } = require('../common/utils').descriptors; + +const ChangYanSpec = { + appid: { + [type]: 'string', + [doc]: 'Changyan comment app ID', + [required]: true, + [requires]: comment => comment.type === 'changyan' + }, + conf: { + [type]: 'string', + [doc]: 'Changyan comment configuration ID', + [required]: true, + [requires]: comment => comment.type === 'changyan' + } +}; + +const DisqusSpec = { + shortname: { + [type]: 'string', + [doc]: 'Disqus shortname', + [required]: true, + [requires]: comment => comment.type === 'disqus' + } +}; + +const GitmentSpec = { + owner: { + [type]: 'string', + [doc]: 'Your GitHub ID', + [required]: true, + [requires]: comment => comment.type === 'gitment' + }, + repo: { + [type]: 'string', + [doc]: 'The repo to store comments', + [required]: true, + [requires]: comment => comment.type === 'gitment' + }, + client_id: { + [type]: 'string', + [doc]: 'Your client ID', + [required]: true, + [requires]: comment => comment.type === 'gitment' + }, + client_secret: { + [type]: 'string', + [doc]: 'Your client secret', + [required]: true, + [requires]: comment => comment.type === 'gitment' + } +}; + +const IssoSpec = { + url: { + [type]: 'string', + [doc]: 'URL to your Isso comment service', + [required]: true, + [requires]: comment => comment.type === 'isso' + } +}; + +const LiveReSpec = { + uid: { + [type]: 'string', + [doc]: 'LiveRe comment service UID', + [required]: true, + [requires]: comment => comment.type === 'livere' + } +}; + +const ValineSpec = { + app_id: { + [type]: 'boolean', + [doc]: 'LeanCloud APP ID', + [required]: true, + [requires]: comment => comment.type === 'valine' + }, + app_key: { + [type]: 'boolean', + [doc]: 'LeanCloud APP key', + [required]: true, + [requires]: comment => comment.type === 'valine' + }, + notify: { + [type]: 'boolean', + [doc]: 'Enable email notification when someone comments', + [defaultValue]: false, + [requires]: comment => comment.type === 'valine' + }, + verify: { + [type]: 'boolean', + [doc]: 'Enable verification code service', + [defaultValue]: false, + [requires]: comment => comment.type === 'valine' + }, + placeholder: { + [type]: 'boolean', + [doc]: 'Placeholder text in the comment box', + [defaultValue]: 'Say something...', + [requires]: comment => comment.type === 'valine' + } +}; + +module.exports = { + [type]: 'object', + [doc]: 'Comment plugin settings (http://ppoffice.github.io/hexo-theme-icarus/categories/Configuration/Comment-Plugins)', + type: { + [type]: 'string', + [doc]: 'Name of the comment plugin', + [defaultValue]: null + }, + ...ChangYanSpec, + ...DisqusSpec, + ...GitmentSpec, + ...IssoSpec, + ...LiveReSpec, + ...ValineSpec +} \ No newline at end of file diff --git a/includes/specs/common.js b/includes/specs/common.js deleted file mode 100644 index f829d67..0000000 --- a/includes/specs/common.js +++ /dev/null @@ -1,24 +0,0 @@ -module.exports = { - projectName: 'Icarus', - is: { - string(value) { - return typeof(value) === 'string'; - }, - array(value) { - return Array.isArray(value); - }, - boolean(value) { - return typeof(value) === 'boolean'; - }, - object(value) { - return typeof(value) === 'object' && value.constructor == Object; - } - }, - descriptor: { - type: Symbol('@type'), - required: Symbol('@required'), - description: Symbol('@description'), - defaultValue: Symbol('@defaultValue'), - condition: Symbol('@condition'), - } -}; \ No newline at end of file diff --git a/includes/specs/config.spec.js b/includes/specs/config.spec.js new file mode 100644 index 0000000..22c55cd --- /dev/null +++ b/includes/specs/config.spec.js @@ -0,0 +1,24 @@ +const { version } = require('../../package.json'); +const { type, required, defaultValue, doc } = require('../common/utils').descriptors; + +module.exports = { + [type]: 'object', + [doc]: 'Root of the configuration file', + [required]: true, + version: { + [type]: 'string', + [doc]: 'Version of the Icarus theme that is currently used', + [required]: true, + [defaultValue]: version + }, + ...require('./meta.spec'), + navbar: require('./navbar.spec'), + footer: require('./footer.spec'), + article: require('./article.spec'), + search: require('./search.spec'), + comment: require('./comment.spec'), + share: require('./share.spec'), + widgets: require('./widgets.spec'), + plugins: require('./plugins.spec'), + providers: require('./providers.spec') +}; \ No newline at end of file diff --git a/includes/specs/footer.spec.js b/includes/specs/footer.spec.js new file mode 100644 index 0000000..948a47a --- /dev/null +++ b/includes/specs/footer.spec.js @@ -0,0 +1,24 @@ +const { doc, type, defaultValue } = require('../common/utils').descriptors; + +module.exports = { + [type]: 'object', + [doc]: 'Footer section link settings', + links: { + ...require('./icon_link.spec'), + [doc]: 'Links to be shown on the right of the footer section', + [defaultValue]: { + 'Creative Commons': { + icon: 'fab fa-creative-commons', + url: 'https://creativecommons.org/' + }, + 'Attribution 4.0 International': { + icon: 'fab fa-creative-commons-by', + url: 'https://creativecommons.org/licenses/by/4.0/' + }, + 'Download on GitHub': { + icon: 'fab fa-github', + url: 'http://github.com/ppoffice/hexo-theme-icarus' + } + } + } +}; \ No newline at end of file diff --git a/includes/specs/icon_link.spec.js b/includes/specs/icon_link.spec.js new file mode 100644 index 0000000..b3cb39a --- /dev/null +++ b/includes/specs/icon_link.spec.js @@ -0,0 +1,20 @@ +const { doc, type, required } = require('../common/utils').descriptors; + +module.exports = { + [type]: 'object', + [doc]: 'Link icon settings', + '*': { + [type]: ['string', 'object'], + [doc]: 'Path or URL to the menu item, and/or link icon class names', + icon: { + [required]: true, + [type]: 'string', + [doc]: 'Link icon class names' + }, + url: { + [required]: true, + [type]: 'string', + [doc]: 'Path or URL to the menu item' + } + } +}; \ No newline at end of file diff --git a/includes/specs/meta.spec.js b/includes/specs/meta.spec.js new file mode 100644 index 0000000..28d96dc --- /dev/null +++ b/includes/specs/meta.spec.js @@ -0,0 +1,52 @@ +const { doc, type, defaultValue } = require('../common/utils').descriptors; + +module.exports = { + favicon: { + [type]: 'string', + [doc]: 'Path or URL to the website\'s icon', + [defaultValue]: null + }, + rss: { + [type]: 'string', + [doc]: 'Path or URL to RSS atom.xml', + [defaultValue]: null + }, + logo: { + [type]: ['string', 'object'], + [defaultValue]: '/images/logo.svg', + [doc]: 'Path or URL to the website\'s logo to be shown on the left of the navigation bar or footer', + text: { + [type]: 'string', + [doc]: 'Text to be shown in place of the logo image' + } + }, + open_graph: { + [type]: 'object', + [doc]: 'Open Graph metadata (https://hexo.io/docs/helpers.html#open-graph)', + fb_app_id: { + [type]: 'string', + [doc]: 'Facebook App ID', + [defaultValue]: null + }, + fb_admins: { + [type]: 'string', + [doc]: 'Facebook Admin ID', + [defaultValue]: null + }, + twitter_id: { + [type]: 'string', + [doc]: 'Twitter ID', + [defaultValue]: null + }, + twitter_site: { + [type]: 'string', + [doc]: 'Twitter site', + [defaultValue]: null + }, + google_plus: { + [type]: 'string', + [doc]: 'Google+ profile link', + [defaultValue]: null + } + } +}; \ No newline at end of file diff --git a/includes/specs/navbar.spec.js b/includes/specs/navbar.spec.js new file mode 100644 index 0000000..1f55457 --- /dev/null +++ b/includes/specs/navbar.spec.js @@ -0,0 +1,31 @@ +const { doc, type, defaultValue } = require('../common/utils').descriptors; + +module.exports = { + [type]: 'object', + [doc]: 'Navigation bar link settings', + menu: { + [type]: 'object', + [doc]: 'Navigation bar menu links', + [defaultValue]: { + Home: '/', + Archives: '/archives', + Categories: '/categories', + Tags: '/tags', + About: '/about' + }, + '*': { + [type]: 'string', + [doc]: 'Path or URL to the menu item' + } + }, + links: { + ...require('./icon_link.spec'), + [doc]: 'Navigation bar links to be shown on the right', + [defaultValue]: { + 'Download on GitHub': { + icon: 'fab fa-github', + url: 'http://github.com/ppoffice/hexo-theme-icarus' + } + } + } +}; \ No newline at end of file diff --git a/includes/specs/plugins.spec.js b/includes/specs/plugins.spec.js new file mode 100644 index 0000000..c8f39e8 --- /dev/null +++ b/includes/specs/plugins.spec.js @@ -0,0 +1,44 @@ +const { doc, type, defaultValue, required, requires } = require('../common/utils').descriptors; + +module.exports = { + [type]: 'object', + [doc]: 'Other plugin settings', + gallery: { + [type]: 'boolean', + [doc]: 'Enable the lightGallery and Justified Gallery plugins', + [defaultValue]: true + }, + 'outdated-browser': { + [type]: 'boolean', + [doc]: 'Enable the Outdated Browser plugin', + [defaultValue]: true + }, + animejs: { + [type]: 'boolean', + [doc]: 'Enable page animations', + [defaultValue]: true + }, + mathjax: { + [type]: 'boolean', + [doc]: 'Enable the MathJax plugin', + [defaultValue]: true + }, + 'google-analytics': { + [type]: ['boolean', 'object'], + [doc]: 'Google Analytics plugin settings (http://ppoffice.github.io/hexo-theme-icarus/2018/01/01/plugin/Analytics/#Google-Analytics)', + tracking_id: { + [type]: 'string', + [doc]: 'Google Analytics tracking id', + [defaultValue]: null + } + }, + 'baidu-analytics': { + [type]: ['boolean', 'object'], + [doc]: 'Baidu Analytics plugin settings (http://ppoffice.github.io/hexo-theme-icarus/2018/01/01/plugin/Analytics/#Baidu-Analytics)', + tracking_id: { + [type]: 'string', + [doc]: 'Baidu Analytics tracking id', + [defaultValue]: null + } + } +}; \ No newline at end of file diff --git a/includes/specs/providers.spec.js b/includes/specs/providers.spec.js new file mode 100644 index 0000000..2b7af1a --- /dev/null +++ b/includes/specs/providers.spec.js @@ -0,0 +1,21 @@ +const { doc, type, defaultValue } = require('../common/utils').descriptors; + +module.exports = { + [type]: 'object', + [doc]: 'CDN provider settings', + cdn: { + [type]: 'string', + [doc]: 'Name or URL of the JavaScript and/or stylesheet CDN provider', + [defaultValue]: 'cdnjs' + }, + fontcdn: { + [type]: 'string', + [doc]: 'Name or URL of the webfont CDN provider', + [defaultValue]: 'google' + }, + iconcdn: { + [type]: 'string', + [doc]: 'Name or URL of the webfont Icon CDN provider', + [defaultValue]: 'fontawesome' + } +}; \ No newline at end of file diff --git a/includes/specs/search.spec.js b/includes/specs/search.spec.js new file mode 100644 index 0000000..7c66071 --- /dev/null +++ b/includes/specs/search.spec.js @@ -0,0 +1,17 @@ +const { doc, type, defaultValue, required, requires } = require('../common/utils').descriptors; + +module.exports = { + [type]: 'object', + [doc]: 'Search plugin settings (http://ppoffice.github.io/hexo-theme-icarus/categories/Configuration/Search-Plugins)', + type: { + [type]: 'string', + [doc]: 'Name of the search plugin', + [defaultValue]: 'insight' + }, + cx: { + [type]: 'string', + [doc]: 'Google CSE cx value', + [required]: true, + [requires]: search => search.type === 'google-cse' + } +}; \ No newline at end of file diff --git a/includes/specs/share.spec.js b/includes/specs/share.spec.js new file mode 100644 index 0000000..ecc1cac --- /dev/null +++ b/includes/specs/share.spec.js @@ -0,0 +1,17 @@ +const { doc, type, defaultValue, required, requires } = require('../common/utils').descriptors; + +module.exports = { + [type]: 'object', + [doc]: 'Share plugin settings (http://ppoffice.github.io/hexo-theme-icarus/categories/Configuration/Share-Plugins)', + type: { + [type]: 'string', + [doc]: 'Share plugin name', + [defaultValue]: null + }, + install_url: { + [type]: 'string', + [doc]: 'URL to the share plugin script provided by share plugin service provider', + [required]: true, + [requires]: share => share.type === 'sharethis' || share.type === 'addthis' + } +} \ No newline at end of file diff --git a/includes/specs/widgets.spec.js b/includes/specs/widgets.spec.js new file mode 100644 index 0000000..f76479e --- /dev/null +++ b/includes/specs/widgets.spec.js @@ -0,0 +1,145 @@ +const { doc, type, defaultValue, required, requires, format } = require('../common/utils').descriptors; + +const DEFAULT_WIDGETS = [ + { + type: 'profile', + position: 'left', + author: 'Your name', + author_title: 'Your title', + location: 'Your location', + avatar: null, + gravatar: null, + follow_link: 'http://github.com/ppoffice', + social_links: { + Github: { + icon: 'fab fa-github', + url: 'http://github.com/ppoffice' + }, + Facebook: { + icon: 'fab fa-facebook', + url: 'http://facebook.com' + }, + Twitter: { + icon: 'fab fa-twitter', + url: 'http://twitter.com' + }, + Dribbble: { + icon: 'fab fa-dribbble', + url: 'http://dribbble.com' + }, + RSS: { + icon: 'fas fa-rss', + url: '/' + } + } + }, + { + type: 'toc', + position: 'left' + }, + { + type: 'links', + position: 'left', + links: { + Hexo: 'https://hexo.io', + Github: 'https://github.com/ppoffice' + } + }, + { + type: 'category', + position: 'left' + }, + { + type: 'tagcloud', + position: 'left' + }, + { + type: 'recent_posts', + position: 'right' + }, + { + type: 'archive', + position: 'right' + }, + { + type: 'tag', + position: 'right' + } +]; + +const ProfileSpec = { + author: { + [type]: 'string', + [doc]: 'Author name to be shown in the profile widget', + [defaultValue]: 'Your name' + }, + author_title: { + [type]: 'string', + [doc]: 'Title of the author to be shown in the profile widget', + [defaultValue]: 'Your title' + }, + location: { + [type]: 'string', + [doc]: 'Author\'s current location to be shown in the profile widget', + [defaultValue]: 'Your location' + }, + avatar: { + [type]: 'string', + [doc]: 'Path or URL to the avatar to be shown in the profile widget', + [defaultValue]: '/images/avatar.png' + }, + gravatar: { + [type]: 'string', + [doc]: 'Email address for the Gravatar to be shown in the profile widget', + }, + follow_link: { + [type]: 'string', + [doc]: 'Path or URL for the follow button', + }, + social_links: { + ...require('./icon_link.spec'), + [doc]: 'Links to be shown on the bottom of the profile widget', + } +}; + +for (let key in ProfileSpec) { + ProfileSpec[key][requires] = widget => widget.type === 'profile'; +} + +const LinksSpec = { + links: { + [type]: 'object', + [doc]: 'Links to be shown in the links widget', + [requires]: parent => parent.type === 'links', + '*': { + [type]: 'string', + [doc]: 'Path or URL to the link', + [required]: true + } + } +}; + +module.exports = { + [type]: 'array', + [doc]: 'Sidebar widget settings', + [defaultValue]: DEFAULT_WIDGETS, + '*': { + [type]: 'object', + [doc]: 'Single widget settings', + type: { + [type]: 'string', + [doc]: 'Widget name', + [required]: true, + [defaultValue]: 'profile' + }, + position: { + [type]: 'string', + [doc]: 'Where should the widget be placed, left or right', + [format]: /^(left|right)$/, + [required]: true, + [defaultValue]: 'left' + }, + ...ProfileSpec, + ...LinksSpec + } +} \ No newline at end of file diff --git a/includes/tasks/check_config.js b/includes/tasks/check_config.js index 984c800..c747b1a 100644 --- a/includes/tasks/check_config.js +++ b/includes/tasks/check_config.js @@ -1,260 +1,47 @@ const fs = require('fs'); +const util = require('util'); const path = require('path'); -const yaml = require('js-yaml'); const logger = require('hexo-log')(); -const Schema = require('js-yaml/lib/js-yaml/schema'); -const Type = require('js-yaml/lib/js-yaml/type'); +const yaml = require('js-yaml'); -const rootSpec = require('../specs/_config.yml'); -const { projectName, is } = require('../specs/common'); -const { type, required, condition, defaultValue, description } = require('../specs/common').descriptor; +const { errors } = require('../common/utils'); +const rootSpec = require('../specs/config.spec'); +const ConfigValidator = require('../common/ConfigValidator'); +const ConfigGenerator = require('../common/ConfigGenerator'); -const UNDEFINED = Symbol('undefined'); const CONFIG_PATH = path.join(__dirname, '../..', '_config.yml'); -const YAML_SCHEMA = new Schema({ - include: [ - require('js-yaml/lib/js-yaml/schema/default_full') - ], - implicit: [ - new Type('tag:yaml.org,2002:null', { - kind: 'scalar', - resolve(data) { - if (data === null) { - return true; - } - const max = data.length; - return (max === 1 && data === '~') || - (max === 4 && (data === 'null' || data === 'Null' || data === 'NULL')); - }, - construct: () => null, - predicate: object => object === null, - represent: { - empty: function () { return ''; } - }, - defaultStyle: 'empty' - }) - ] -}); - -function toPlainObject(object, depth) { - if (object === null || (!is.object(object) && !is.array(object))) { - return object; - } - if (depth <= 0) { - return is.array(object) ? '[Array]' : '[Object]'; - } - if (is.array(object)) { - const result = []; - for (let child of object) { - result.push(toPlainObject(child, depth - 1)); - } - return result; - } - const result = {}; - for (let key in object) { - result[key] = toPlainObject(object[key], depth - 1); - } - for (let key of Object.getOwnPropertySymbols(object)) { - result[key.toString()] = toPlainObject(object[key], depth - 1); - } - return result; -} - -function isValidSpec(spec) { - if (!spec.hasOwnProperty(type)) { - return false; - } - return true; -} - -function checkPrecondition(spec, parameter) { - if (!spec.hasOwnProperty(condition)) { - return true; - } - try { - if (spec[condition](parameter) === true) { - return true; - } - } catch (e) { } - return false; -} - -function createDefaultConfig(spec, parentConfig = null) { - if (!isValidSpec(spec)) { - return UNDEFINED; - } - if (!checkPrecondition(spec, parentConfig)) { - return UNDEFINED; - } - if (spec.hasOwnProperty(defaultValue)) { - return spec[defaultValue]; - } - const types = is.array(spec[type]) ? spec[type] : [spec[type]]; - if (types.includes('object')) { - let defaults = UNDEFINED; - for (let key in spec) { - if (typeof (key) === 'symbol' || key === '*') { - continue; - } - const value = createDefaultConfig(spec[key], defaults); - if (value !== UNDEFINED) { - if (defaults === UNDEFINED) { - defaults = {}; - } - if (spec[key].hasOwnProperty(description)) { - defaults['#' + key] = spec[key][description]; - } - defaults[key] = value; - } - } - return defaults; - } else if (types.includes('array') && spec.hasOwnProperty('*')) { - return [createDefaultConfig(spec['*'], {})]; - } - return UNDEFINED; -} - -function dumpConfig(config, path) { - const configYaml = yaml.safeDump(config, { - indent: 4, - lineWidth: 1024, - schema: YAML_SCHEMA - }).replace(/^(\s*)\'#.*?\':\s*\'*(.*?)\'*$/mg, '$1# $2'); - fs.writeFileSync(path, configYaml); -} - -function validateConfigVersion(config, spec) { - function getMajorVersion(version) { - try { - return parseInt(version.split('.')[0]); - } catch (e) { - logger.error(`Configuration version number ${version} is malformed.`); - } - return null; - } - if (!config.hasOwnProperty('version')) { - logger.error('Failed to get the version number of the configuration file.'); - logger.warn('You are probably using a previous version of confiugration.'); - logger.warn(`Please be noted that it may not work in the newer versions of ${projectName}.`); - return false; - } - const specMajorVersion = getMajorVersion(spec.version[defaultValue]); - const configMajorVersion = getMajorVersion(config.version); - if (configMajorVersion === null || specMajorVersion === null) { - return false; - } - if (configMajorVersion < specMajorVersion) { - logger.warn('You are using a previous version of confiugration.'); - logger.warn(`Please be noted that it may not work in the newer versions of ${projectName}.`); - return false; - } - if (configMajorVersion > specMajorVersion) { - logger.warn('You are probably using a more recent version of confiugration.'); - logger.warn(`Please be noted that it may not work in the previous versions of ${projectName}.`); - return false; - } - return true; -} - -function validateConfigType(config, specTypes) { - specTypes = is.array(specTypes) ? specTypes : [specTypes]; - for (let specType of specTypes) { - if (is[specType](config)) { - return specType; - } - } - logger.error(`Config ${toPlainObject(config, 2)} do not match types ${specTypes}`); - return null; -} - -const INVALID_SPEC = Symbol(); -const MISSING_REQUIRED = Symbol(); -const INVALID_TYPE = Symbol(); - -function validateConfigAndWarn(config, spec, parentConfig, configPath = []) { - const result = validateConfig(config, spec, parentConfig, configPath); - if (result !== true) { - const pathString = configPath.join('.'); - const specTypes = is.array(spec[type]) ? spec[type] : [spec[type]]; - switch(result) { - case INVALID_SPEC: - logger.error(`Invalid specification! The specification '${pathString}' does not have a [type] field:`); - logger.error('The specification of this configuration is:'); - logger.error(JSON.stringify(toPlainObject(spec, 2), null, 4)); - break; - case MISSING_REQUIRED: - logger.error(`Configuration '${pathString}' in required by the specification but is missing from the configuration!`); - logger.error('The specification of this configuration is:'); - logger.error(JSON.stringify(toPlainObject(spec, 2), null, 4)); - break; - case INVALID_TYPE: - logger.error(`Type mismatch! Configuration '${pathString}' is not the '${specTypes.join(' or ')}' type.`); - logger.error('The configuration value is:'); - logger.error(JSON.stringify(toPlainObject(config, 2), null, 4)); - logger.error('The specification of this configuration is:'); - logger.error(JSON.stringify(toPlainObject(spec, 2), null, 4)); - break; - } - } - return result; -} - -function validateConfig(config, spec, parentConfig = null, configPath = []) { - if (!isValidSpec(spec)) { - return INVALID_SPEC; - } - if (!checkPrecondition(spec, parentConfig)) { - return true; - } - if (typeof(config) === 'undefined' || config === null) { - if (spec[required] === true) { - return MISSING_REQUIRED; - } - return true; - } - const configType = validateConfigType(config, spec[type]); - if (configType === null) { - return INVALID_TYPE; - } - if (configType === 'array' && spec.hasOwnProperty('*')) { - for (let i = 0; i < config.length; i++) { - if (!validateConfigAndWarn(config[i], spec['*'], config, configPath.concat(`[${i}]`))) { - return false; - } - } - } else if (configType === 'object') { - for (let key in spec) { - if (key === '*') { - for (let configKey in config) { - if (!validateConfigAndWarn(config[configKey], spec['*'], config, configPath.concat(configKey))) { - return false; - } - } - } else { - if (!validateConfigAndWarn(config[key], spec[key], config, configPath.concat(key))) { - return false; - } - } - } - } - return true; -} logger.info('Checking if the configuration file exists...'); if (!fs.existsSync(CONFIG_PATH)) { const relativePath = path.relative(process.cwd(), CONFIG_PATH); logger.warn(`${relativePath} is not found. We are creating one for you...`); - dumpConfig(createDefaultConfig(rootSpec), CONFIG_PATH); + fs.writeFileSync(CONFIG_PATH, new ConfigGenerator(rootSpec).generate()); logger.info(`${relativePath} is created. Please restart Hexo to apply changes.`); process.exit(0); } logger.info('Validating the configuration file...'); +const validator = new ConfigValidator(rootSpec); const config = yaml.safeLoad(fs.readFileSync(CONFIG_PATH)); -if (!validateConfigVersion(config, rootSpec)) { - logger.info(`To let ${projectName} create a fresh configuration file for you, please delete or rename the following file:`); - logger.info(CONFIG_PATH); -} else { - validateConfigAndWarn(config, rootSpec); +try { + validator.validate(config); +} catch (e) { + if (e instanceof errors.ConfigError) { + logger.error(e.message); + if (e.hasOwnProperty('spec')) { + logger.error('The specification of this configuration is:'); + logger.error(util.inspect(e.spec)); + } + if (e.hasOwnProperty('config')) { + logger.error('Configuration value is:'); + logger.error(util.inspect(e.config)); + } + } else if (e instanceof errors.VersionError) { + logger.error(e.message); + logger.warn(`To let us create a fresh configuration file for you, please rename or delete the following file:`); + logger.warn(CONFIG_PATH); + } else { + throw e; + } }