refactor(scripts): split specs and checking into separate files

This commit is contained in:
ppoffice 2018-10-20 23:18:58 -04:00
parent b6bd53a1a7
commit 80c235822d
19 changed files with 929 additions and 740 deletions

View File

@ -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;

View File

@ -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;

148
includes/common/utils.js Normal file
View File

@ -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
};

View File

@ -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) {

View File

@ -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'
}
}
};

View File

@ -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
}
};

View File

@ -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
}

View File

@ -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'),
}
};

View File

@ -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')
};

View File

@ -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'
}
}
}
};

View File

@ -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'
}
}
};

View File

@ -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
}
}
};

View File

@ -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'
}
}
}
};

View File

@ -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
}
}
};

View File

@ -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'
}
};

View File

@ -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'
}
};

View File

@ -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'
}
}

View File

@ -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
}
}

View File

@ -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;
}
}