refactor(schema): rewrite config generator & add migration

This commit is contained in:
ppoffice 2019-12-25 22:51:14 -05:00
parent 46530f3c4e
commit 02d1996215
35 changed files with 610 additions and 572 deletions

2
.gitignore vendored
View File

@ -106,5 +106,5 @@ dist
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
_config.yml
_config.yml*
yarn.lock

View File

@ -1,103 +0,0 @@
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 appendDoc(spec, defaults) {
if (defaults === null) {
return null;
}
if (is.array(defaults) && spec.hasOwnProperty('*')) {
return defaults.map(value => appendDoc(spec['*'], value));
} else if (is.object(defaults)) {
const _defaults = {};
for (let key in defaults) {
if (spec.hasOwnProperty(key) && spec[key].hasOwnProperty(doc)) {
let i = 0;
for (let line of spec[key][doc].split('\n')) {
_defaults['#' + key + i++] = line;
}
}
_defaults[key] = appendDoc(spec.hasOwnProperty(key) ? spec[key] : {}, defaults[key]);
}
return _defaults;
}
return defaults;
}
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 appendDoc(spec, 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 = {};
}
defaults[key] = value;
}
}
return appendDoc(spec, 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

@ -1,132 +0,0 @@
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 || base.minor !== ver.minor;
}
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;

View File

@ -1,151 +0,0 @@
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 = (() => ({
number(value) {
return typeof (value) === 'number';
},
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

@ -0,0 +1 @@
module.exports = require('./v2_v3');

View File

@ -0,0 +1,89 @@
const logger = require('hexo-log')();
const deepmerge = require('deepmerge');
const Migration = require('../util/migrate').Migration;
module.exports = class extends Migration {
constructor() {
super('3.0.0', null);
}
doMigrate(config) {
const result = deepmerge({}, config);
result.head = {
favicon: config.favicon || null,
canonical_url: config.canonical_url || null,
open_graph: config.open_graph || null,
meta: config.meta || null,
rss: config.rss || null
};
delete result.favicon;
delete result.canonical_url;
delete result.open_graph;
delete result.meta;
delete result.rss;
if (result.search && Object.prototype.hasOwnProperty.call(result.search, 'type')) {
switch (result.search.type) {
case 'google-cse':
result.search.type = 'google_cse';
break;
}
}
if (result.comment && Object.prototype.hasOwnProperty.call(result.comment, 'type')) {
switch (result.comment.type) {
case 'changyan':
result.comment.app_id = config.comment.appid;
delete result.comment.appid;
break;
}
}
if (Array.isArray(result.widgets) && result.widgets.length) {
for (const widget of result.widgets) {
if (Object.prototype.hasOwnProperty.call(widget, 'type')) {
switch (widget.type) {
case 'archive':
widget.type = 'archives';
break;
case 'category':
widget.type = 'categories';
break;
case 'tag':
widget.type = 'tags';
break;
case 'tagcloud':
logger.warn('The tagcloud widget has been removed from Icarus in version 3.0.0.');
logger.warn('Please remove it from your configuration file.');
break;
}
}
}
}
if (result.plugins && typeof result.plugins === 'object') {
for (const name in result.plugins) {
switch (name) {
case 'outdated-browser':
result.plugins.outdated_browser = result.plugins[name];
delete result.plugins[name];
break;
case 'back-to-top':
result.plugins.back_to_top = result.plugins[name];
delete result.plugins[name];
break;
case 'baidu-analytics':
result.plugins.baidu_analytics = result.plugins[name];
delete result.plugins[name];
break;
case 'google-analytics':
result.plugins.google_analytics = result.plugins[name];
delete result.plugins[name];
break;
}
}
}
return result;
}
};

View File

@ -12,7 +12,7 @@
"type": "string",
"description": "Changyan app ID"
},
"shortname": {
"conf": {
"type": "string",
"description": "Changyan configuration ID"
}

View File

@ -34,17 +34,20 @@
},
"admin": {
"type": "string",
"description": "Disqus moderator username"
"description": "Disqus moderator username",
"nullable": true
},
"admin_label": {
"type": "string",
"description": "Disqus moderator badge text",
"default": false
"default": false,
"nullable": true
},
"nesting": {
"type": "integer",
"description": "Maximum number of comment nesting level",
"default": 4
"default": 4,
"nullable": true
}
},
"required": [

View File

@ -34,35 +34,42 @@
"per_page": {
"type": "number",
"description": "Pagination size, with maximum 100",
"default": 10
"default": 10,
"nullable": true
},
"distraction_free_mode": {
"type": "boolean",
"description": "Facebook-like distraction free mode",
"default": false
"default": false,
"nullable": true
},
"pager_direction": {
"type": "string",
"description": "Comment sorting direction, available values are `last` and `first`",
"default": "last"
"default": "last",
"nullable": true
},
"create_issue_manually": {
"type": "boolean",
"description": "Create GitHub issues manually for each page",
"default": false
"default": false,
"nullable": true
},
"proxy": {
"type": "string",
"description": "GitHub oauth request reverse proxy for CORS"
"description": "GitHub oauth request reverse proxy for CORS",
"nullable": true
},
"flip_move_options": {
"type": "object",
"description": "Comment list animation"
"description": "Comment list animation",
"nullable": true
},
"enable_hotkey": {
"type": "boolean",
"description": "Enable hot key (cmd|ctrl + enter) submit comment",
"default": true
"default": true,
"nullable": true
}
},
"required": [

View File

@ -27,17 +27,20 @@
"theme": {
"type": "string",
"description": "An optional Gitment theme object",
"default": "gitment.defaultTheme"
"default": "gitment.defaultTheme",
"nullable": true
},
"per_page": {
"type": "number",
"description": "An optional number to which comments will be paginated",
"default": 20
"default": 20,
"nullable": true
},
"max_comment_height": {
"type": "number",
"description": "An optional number to limit comments' max height, over which comments will be folded",
"default": 250
"default": 250,
"nullable": true
}
},
"required": [

View File

@ -18,17 +18,20 @@
},
"placeholder": {
"type": "string",
"description": "Comment box placeholders"
"description": "Comment box placeholders",
"nullable": true
},
"notify": {
"type": "boolean",
"description": "Enable email notification when someone comments",
"default": false
"default": false,
"nullable": true
},
"verify": {
"type": "boolean",
"description": "Enable verification code service",
"default": false
"default": false,
"nullable": true
},
"avatar": {
"type": "string",
@ -44,12 +47,14 @@
"hide",
"mm"
],
"default": "mm"
"default": "mm",
"nullable": true
},
"avatar_force": {
"type": "boolean",
"description": "Pull the latest avatar upon page visit",
"default": false
"default": false,
"nullable": true
},
"meta": {
"type": "array",
@ -61,27 +66,32 @@
"nick",
"mail",
"link"
]
],
"nullable": true
},
"page_size": {
"type": "integer",
"description": "Number of comments per page",
"default": 10
"default": 10,
"nullable": true
},
"visitor": {
"type": "boolean",
"description": "Show visitor count",
"default": false
"default": false,
"nullable": true
},
"highlight": {
"type": "boolean",
"description": "Enable code highlighting",
"default": true
"default": true,
"nullable": true
},
"record_ip": {
"type": "boolean",
"description": "Record reviewer IP address",
"default": false
"default": false,
"nullable": true
}
},
"required": [

View File

@ -11,12 +11,14 @@
"theme": {
"type": "string",
"description": "Code highlight themes\nhttps://github.com/highlightjs/highlight.js/tree/master/src/styles",
"default": "atom-one-light"
"default": "atom-one-light",
"nullable": true
},
"clipboard": {
"type": "string",
"type": "boolean",
"description": "Show copy code button",
"default": true
"default": true,
"nullable": true
},
"fold": {
"type": "string",
@ -26,19 +28,23 @@
"folded",
"unfolded"
],
"default": "unfolded"
"default": "unfolded",
"nullable": true
}
}
},
"nullable": true
},
"thumbnail": {
"type": "boolean",
"description": "Whether to show thumbnail image for every article",
"default": true
"default": true,
"nullable": true
},
"readtime": {
"type": "boolean",
"description": "Whether to show estimated article reading time",
"default": true
"default": true,
"nullable": true
}
}
}

View File

@ -7,11 +7,13 @@
"favicon": {
"type": "string",
"description": "URL or path to the website's icon",
"default": "/img/favicon.svg"
"default": "/img/favicon.svg",
"nullable": true
},
"canonical_url": {
"type": "string",
"description": "Canonical URL of the current page"
"description": "Canonical URL of the current page",
"nullable": true
},
"open_graph": {
"$ref": "/misc/open_graph.json"
@ -21,7 +23,8 @@
},
"rss": {
"type": "string",
"description": "URL or path to the website's RSS atom.xml"
"description": "URL or path to the website's RSS atom.xml",
"nullable": true
}
}
}

View File

@ -21,7 +21,8 @@
"Tags": "/tags",
"About": "/about"
}
]
],
"nullable": true
},
"links": {
"$ref": "/misc/poly_links.json",

View File

@ -7,17 +7,20 @@
"cdn": {
"type": "string",
"description": "Name or URL template of the JavaScript and/or stylesheet CDN provider",
"default": "jsdelivr"
"default": "jsdelivr",
"nullable": true
},
"fontcdn": {
"type": "string",
"description": "Name or URL template of the webfont CDN provider",
"default": "google"
"default": "google",
"nullable": true
},
"iconcdn": {
"type": "string",
"description": "Name or URL of the webfont Icon CDN provider",
"default": "fontawesome"
"default": "fontawesome",
"nullable": true
}
}
}

View File

@ -6,7 +6,8 @@
"properties": {
"version": {
"type": "string",
"description": "Version of the configuration file"
"description": "Version of the configuration file",
"default": "3.0.0"
},
"logo": {
"type": [

View File

@ -6,7 +6,7 @@
"properties": {
"type": {
"type": "string",
"const": "patreon"
"const": "buymeacoffee"
},
"url": {
"type": "string",

View File

@ -23,6 +23,6 @@
"required": [
"type",
"business",
"currencyCode"
"currency_code"
]
}

View File

@ -6,5 +6,6 @@
"items": {
"type": "string",
"description": "Meta tag specified in <attribute>=<value> style\nE.g., name=theme-color;content=#123456 => <meta name=\"theme-color\" content=\"#123456\">"
}
},
"nullable": true
}

View File

@ -6,16 +6,19 @@
"properties": {
"title": {
"type": "string",
"description": "Page title (og:title)"
"description": "Page title (og:title)",
"nullable": true
},
"type": {
"type": "string",
"description": "Page type (og:type)",
"default": "blog"
"default": "blog",
"nullable": true
},
"url": {
"type": "string",
"description": "Page URL (og:url)"
"description": "Page URL (og:url)",
"nullable": true
},
"image": {
"type": [
@ -25,39 +28,49 @@
"description": "Page cover (og:image)",
"items": {
"type": "string"
}
},
"nullable": true
},
"site_name": {
"type": "string",
"description": "Site name (og:site_name)"
"description": "Site name (og:site_name)",
"nullable": true
},
"description": {
"type": "string",
"description": "Page description (og:description)"
"description": "Page description (og:description)",
"nullable": true
},
"twitter_card": {
"type": "string",
"description": "Twitter card type (twitter:card)"
"description": "Twitter card type (twitter:card)",
"nullable": true
},
"twitter_id": {
"type": "string",
"description": "Twitter ID (twitter:creator)"
"description": "Twitter ID (twitter:creator)",
"nullable": true
},
"twitter_site": {
"type": "string",
"description": "Twitter ID (twitter:creator)"
"description": "Twitter ID (twitter:creator)",
"nullable": true
},
"google_plus": {
"type": "string",
"description": "Google+ profile link (deprecated)"
"description": "Google+ profile link (deprecated)",
"nullable": true
},
"fb_admins": {
"type": "string",
"description": "Facebook admin ID"
"description": "Facebook admin ID",
"nullable": true
},
"fb_app_id": {
"type": "string",
"description": "Facebook App ID"
"description": "Facebook App ID",
"nullable": true
}
}
},
"nullable": true
}

View File

@ -36,5 +36,6 @@
"icon": "fab fa-github"
}
}
]
],
"nullable": true
}

View File

@ -5,8 +5,9 @@
"type": "object",
"properties": {
"tracking_id": {
"type": "object",
"description": "Baidu Analytics tracking ID"
"type": "string",
"description": "Baidu Analytics tracking ID",
"nullable": true
}
},
"required": [

View File

@ -5,8 +5,9 @@
"type": "object",
"properties": {
"tracking_id": {
"type": "object",
"description": "Google Analytics tracking ID"
"type": "string",
"description": "Google Analytics tracking ID",
"nullable": true
}
},
"required": [

View File

@ -9,7 +9,11 @@
"string",
"number"
],
"description": "Hotjar site id"
"description": "Hotjar site id",
"nullable": true
}
}
},
"required": [
"site_id"
]
}

View File

@ -22,7 +22,8 @@
"Hexo": "https://hexo.io",
"Bulma": "https://bulma.io"
}
]
],
"nullable": true
}
},
"required": [

View File

@ -6,48 +6,56 @@
"properties": {
"type": {
"type": "string",
"const": "profile"
"const": "profile",
"nullable": true
},
"author": {
"type": "string",
"description": "Author name",
"examples": [
"Your name"
]
],
"nullable": true
},
"author_title": {
"type": "string",
"description": "Author title",
"examples": [
"Your title"
]
],
"nullable": true
},
"location": {
"type": "string",
"description": "Author's current location",
"examples": [
"Your location"
]
],
"nullable": true
},
"avatar": {
"type": "string",
"description": "URL or path to the avatar image"
"description": "URL or path to the avatar image",
"nullable": true
},
"avatar_rounded": {
"type": "boolean",
"description": "Whether show the rounded avatar image",
"default": false
"default": false,
"nullable": true
},
"gravatar": {
"type": "string",
"description": "Email address for the Gravatar"
"description": "Email address for the Gravatar",
"nullable": true
},
"follow_link": {
"type": "string",
"description": "URL or path for the follow button",
"examples": [
"https://github.com/ppoffice"
]
],
"nullable": true
},
"social_links": {
"$ref": "/misc/poly_links.json",

View File

@ -10,7 +10,8 @@
},
"description": {
"type": "string",
"description": "Hint text under the email input"
"description": "Hint text under the email input",
"nullable": true
},
"feedburner_id": {
"type": "string",

View File

@ -1,46 +0,0 @@
const fs = require('fs');
const util = require('util');
const path = require('path');
const logger = require('hexo-log')();
const yaml = require('js-yaml');
const { errors } = require('../common/utils');
const rootSpec = require('../specs/config.spec');
const ConfigValidator = require('../common/ConfigValidator');
const ConfigGenerator = require('../common/ConfigGenerator');
const CONFIG_PATH = path.join(__dirname, '../..', '_config.yml');
logger.info('Validating the configuration file');
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...`);
fs.writeFileSync(CONFIG_PATH, new ConfigGenerator(rootSpec).generate());
logger.info(`${relativePath} is created. Please restart Hexo to apply changes.`);
process.exit(0);
}
const validator = new ConfigValidator(rootSpec);
const config = yaml.safeLoad(fs.readFileSync(CONFIG_PATH));
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;
}
}

View File

@ -1,23 +0,0 @@
const logger = require('hexo-log')();
const packageInfo = require('../../package.json');
// FIXME: will not check against package version
function checkDependency(name) {
try {
require.resolve(name);
return true;
} catch (e) {
logger.error(`Package ${name} is not installed.`);
}
return false;
}
logger.info('Checking if required dependencies are installed...');
const missingDeps = Object.keys(packageInfo.peerDependencies)
.map(checkDependency)
.some(installed => !installed);
if (missingDeps) {
logger.error('Please install the missing dependencies from the root directory of your Hexo site.');
/* eslint no-process-exit: "off" */
process.exit(-1);
}

View File

@ -1,10 +0,0 @@
const logger = require('hexo-log')();
logger.info(`=======================================
=============================================`);

110
include/util/migrate.js Normal file
View File

@ -0,0 +1,110 @@
const path = require('path');
const logger = require('hexo-log')();
class Version {
constructor(version) {
const ver = version.split('.').map(i => parseInt(i, 10));
if (ver.length !== 3) {
throw new Error('Malformed version number ' + version);
}
this.major = ver[0];
this.minor = ver[1];
this.patch = ver[2];
}
toString() {
return `${this.major}.${this.minor}.${this.patch}`;
}
}
Version.compare = function(a, b) {
if (!(a instanceof Version) || !(b instanceof Version)) {
throw new Error('Cannot compare non-Versions');
}
if (a.major !== b.major) {
return a.major - b.major;
}
if (a.minor !== b.minor) {
return a.minor - b.minor;
}
if (a.patch !== b.patch) {
return a.patch - b.patch;
}
return 0;
};
class Migration {
/**
* @param {string} version Target version
* @param {string} head File name of the previous migration
*/
constructor(version, head) {
this.version = new Version(version);
this.head = head;
}
doMigrate(config) {
throw new Error('Not implemented!');
}
migrate(config) {
logger.info(`Updating configurations from ${config.version} to ${this.version.toString()}...`);
const result = this.doMigrate(config);
result.version = this.version.toString();
return result;
}
}
class Migrator {
constructor(root) {
this.versions = [];
this.migrations = {};
let head = 'head';
while (head) {
const migration = new(require(path.join(root, head)))();
if (!(migration instanceof Migration)) {
throw new Error(`Migration ${head} is not a Migration class.`);
}
this.versions.push(migration.version);
this.migrations[migration.version.toString()] = migration;
head = migration.head;
}
this.versions.sort(Version.compare);
}
isOudated(version) {
if (!this.versions.length) {
return false;
}
return Version.compare(new Version(version), this.getLatestVersion()) < 0;
}
getLatestVersion() {
if (!this.versions.length) {
return null;
}
return this.versions[this.versions.length - 1];
}
migrate(config, toVersion = null) {
const fVer = new Version(config.version);
const tVer = toVersion ? new Version(toVersion) : this.getLatestVersion();
// find all migrations whose version is larger than fromVer, smaller or equal to toVer
// and run migrations on the config one by one
return this.versions.filter(ver => Version.compare(ver, fVer) > 0 && Version.compare(ver, tVer) <= 0)
.sort(Version.compare)
.reduce((cfg, ver) => {
const migration = this.migrations[ver.toString()];
return migration.migrate(cfg);
}, config);
}
}
Migrator.Version = Version;
Migrator.Migration = Migration;
module.exports = Migrator;

View File

@ -1,6 +1,9 @@
const Ajv = require('ajv');
const path = require('path');
const deepmerge = require('deepmerge');
const yaml = require('./yaml');
const MAGIC = 'c823d4d4';
const PRIMITIVE_DEFAULTS = {
'null': null,
@ -23,16 +26,84 @@ class DefaultValue {
this.description = source.description;
}
if ('value' in source && source.value) {
this.value = deepmerge(this.value, source.value);
if (this.value instanceof DefaultValue) {
this.value.merge(source.value);
} else if (Array.isArray(this.value) && Array.isArray(source.value)) {
this.value.concat(...source.value);
} else if (typeof this.value === 'object' && typeof source.value === 'object') {
for (const key in source.value) {
this.value[key] = source.value[key];
}
} else {
this.value = deepmerge(this.value, source.value);
}
}
return this;
}
toString() {
return '[DefaultValue]' + JSON.stringify(value);
clone() {
const result = new DefaultValue(this.value, this.description);
if (result.value instanceof DefaultValue) {
result.value = result.value.clone();
} else if (Array.isArray(result.value)) {
result.value = [].concat(...result.value);
} else if (typeof result.value === 'object') {
result.value = Object.assign({}, result.value);
}
return result;
}
toCommentedArray() {
return [].concat(...this.value.map(item => {
if (item instanceof DefaultValue) {
if (typeof item.description !== 'string' || !item.description.trim()) {
return [item.toCommented()];
}
return item.description.split('\n').map((line, i) => {
return MAGIC + i + ': ' + line;
}).concat(item.toCommented());
}
return [item];
}));
}
toCommentedObject() {
if (this.value instanceof DefaultValue) {
return this.value.toCommented();
}
const result = {};
for (const key in this.value) {
const item = this.value[key];
if (item instanceof DefaultValue) {
if (typeof item.description === 'string' && item.description.trim()) {
item.description.split('\n').forEach((line, i) => {
result[MAGIC + key + i] = line;
});
}
result[key] = item.toCommented();
} else {
result[key] = item;
}
}
return result;
}
toCommented() {
if (Array.isArray(this.value)) {
return this.toCommentedArray();
} else if (typeof this.value === 'object' && this.value !== null) {
return this.toCommentedObject();
}
return this.value;
}
toYaml() {
const regex = new RegExp('^(\\s*)(?:-\\s*\\\')?' + MAGIC + '.*?:\\s*\\\'?(.*?)\\\'*$', 'mg');
return yaml.stringify(this.toCommented()).replace(regex, '$1# $2');// restore comments
}
}
/* eslint-disable no-use-before-define */
class Schema {
constructor(loader, def) {
if (!(loader instanceof SchemaLoader)) {
@ -55,37 +126,40 @@ class Schema {
getArrayDefaultValue(def) {
let value;
const defaultValue = new DefaultValue(null, def.description);
if ('items' in def && typeof def.items === 'object') {
const items = Object.assign({}, def.items);
delete items.oneOf;
value = this.getDefaultValue(items);
}
if ('oneOf' in def.items && Array.isArray(def.items.oneOf)) {
value = def.items.oneOf.map(one => {
if (!value) {
defaultValue.value = def.items.oneOf.map(one => {
if (!(value instanceof DefaultValue)) {
return this.getDefaultValue(one);
}
return new DefaultValue(value.value, value.description)
.merge(this.getDefaultValue(one));
return value.clone().merge(this.getDefaultValue(one));
});
} else {
if (!Array.isArray(value)) {
value = [value];
}
defaultValue.value = value;
}
if (!Array.isArray(value)) {
value = [value];
}
return new DefaultValue(value, def.description);
return defaultValue;
}
getObjectDefaultValue(def) {
let value = {};
const value = {};
if ('properties' in def && typeof def.properties === 'object') {
for (let property in def.properties) {
for (const property in def.properties) {
value[property] = this.getDefaultValue(def.properties[property]);
}
}
const defaultValue = new DefaultValue(value, def.description);
if ('oneOf' in def && Array.isArray(def.oneOf) && def.oneOf.length) {
value = deepmerge(value, this.getDefaultValue(def.oneOf[0]));
return defaultValue.merge(this.getDefaultValue(def.oneOf[0]));
}
return new DefaultValue(value, def.description);
return defaultValue;
}
getTypedDefaultValue(def) {
@ -96,9 +170,13 @@ class Schema {
} else if (type === 'object') {
defaultValue = this.getObjectDefaultValue(def);
} else if (type in PRIMITIVE_DEFAULTS) {
defaultValue = new DefaultValue(PRIMITIVE_DEFAULTS[type], def.description);
if ('nullable' in def && def.nullable) {
defaultValue = new DefaultValue(null, def.description);
} else {
defaultValue = new DefaultValue(PRIMITIVE_DEFAULTS[type], def.description);
}
} else {
throw new Error(`Cannot get default value for type ${type}`)
throw new Error(`Cannot get default value for type ${type}`);
}
// referred default value always get overwritten by its parent default value
if ('$ref' in def && def.$ref) {
@ -120,10 +198,10 @@ class Schema {
def = this.def;
}
if ('const' in def) {
return new DefaultValue(def['const'], def.description);
return new DefaultValue(def.const, def.description);
}
if ('default' in def) {
return new DefaultValue(def['default'], def.description);
return new DefaultValue(def.default, def.description);
}
if ('examples' in def && Array.isArray(def.examples) && def.examples.length) {
return new DefaultValue(def.examples[0], def.description);
@ -141,7 +219,7 @@ class Schema {
class SchemaLoader {
constructor() {
this.schemas = {};
this.ajv = new Ajv();
this.ajv = new Ajv({ nullable: true });
}
getSchema($id) {
@ -153,11 +231,11 @@ class SchemaLoader {
throw new Error('The schema definition does not have an $id field');
}
this.ajv.addSchema(def);
this.schemas[def['$id']] = new Schema(this, def);
this.schemas[def.$id] = new Schema(this, def);
}
removeSchema($id) {
this.ajv.removeSchema(def);
this.ajv.removeSchema($id);
delete this.schemas[$id];
}
@ -204,7 +282,7 @@ SchemaLoader.load = (rootSchemaDef, resolveDirs = []) => {
} catch (e) {
continue;
}
if (typeof def !== 'object' || def['$id'] !== $ref) {
if (typeof def !== 'object' || def.$id !== $ref) {
continue;
}
loader.addSchema(def);
@ -217,6 +295,10 @@ SchemaLoader.load = (rootSchemaDef, resolveDirs = []) => {
traverseObj(rootSchemaDef, '$ref', handler);
return loader;
}
};
module.exports = SchemaLoader;
module.exports = {
Schema,
SchemaLoader,
DefaultValue
};

43
include/util/yaml.js Normal file
View File

@ -0,0 +1,43 @@
const yaml = require('js-yaml');
const YamlType = require('js-yaml/lib/js-yaml/type');
const YamlSchema = require('js-yaml/lib/js-yaml/schema');
// output null as empty in yaml
const YAML_SCHEMA = new YamlSchema({
include: [
require('js-yaml/lib/js-yaml/schema/default_full')
],
implicit: [
new YamlType('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: () => ''
},
defaultStyle: 'empty'
})
]
});
module.exports = {
parse(str) {
return yaml.safeLoad(str);
},
stringify(object) {
return yaml.safeDump(object, {
indent: 4,
lineWidth: 1024,
schema: YAML_SCHEMA
});
}
};

View File

@ -27,6 +27,7 @@
"hexo-log": "^1.0.0",
"hexo-pagination": "^1.0.0",
"hexo-renderer-inferno": "^0.1.1",
"hexo-renderer-stylus": "^1.1.0",
"hexo-util": "^1.8.0",
"inferno": "^7.3.3",
"inferno-create-element": "^7.3.3",

View File

@ -1,7 +1,119 @@
const fs = require('fs');
const path = require('path');
const util = require('util');
const crypto = require('crypto');
const logger = require('hexo-log')();
const packageInfo = require('../package.json');
/**
* Print welcome message
*/
logger.info(`=======================================
=============================================`);
/**
* Check if all dependencies are installed
*/
// FIXME: will not check against package version
function checkDependency(name) {
try {
require.resolve(name);
return true;
} catch (e) {
logger.error(`Package ${name} is not installed.`);
}
return false;
}
logger.info('=== Checking package dependencies ===');
const missingDeps = Object.keys(packageInfo.peerDependencies)
.map(checkDependency)
.some(installed => !installed);
if (missingDeps) {
logger.error('Please install the missing dependencies from the root directory of your Hexo site.');
/* eslint no-process-exit: "off" */
process.exit(-1);
}
/**
* Configuration file checking and migration
*/
if (!process.argv.includes('--icarus-dont-check-config')) {
const SCHEMA_ROOT = path.join(hexo.theme_dir, 'include/schema/');
const CONFIG_PATH = path.join(hexo.theme_dir, '_config.yml');
const yaml = require('../include/util/yaml');
const { SchemaLoader } = require('../include/util/schema');
const loader = SchemaLoader.load(require(path.join(SCHEMA_ROOT, 'config.json')), SCHEMA_ROOT);
const schema = loader.getSchema('/config.json');
logger.info('=== Checking the configuration file ===');
// Generate config file if not exist
if (!process.argv.includes('--icarus-dont-generate-config')) {
if (!fs.existsSync(CONFIG_PATH)) {
logger.warn(`${CONFIG_PATH} is not found. We are creating one for you...`);
logger.info('You may add \'--icarus-dont-generate-config\' to prevent creating the configuration file.');
const defaultValue = schema.getDefaultValue();
fs.writeFileSync(CONFIG_PATH, defaultValue.toYaml());
logger.info(`${CONFIG_PATH} created successfully.`);
}
}
try {
const cfgStr = fs.readFileSync(CONFIG_PATH);
let cfg = yaml.parse(cfgStr);
// Check config version
if (!process.argv.includes('--icarus-dont-upgrade-config')) {
const migrator = new(require('../include/util/migrate'))(path.join(hexo.theme_dir, 'include/migration'));
// Upgrade config
if (migrator.isOudated(cfg.version)) {
logger.info(`Your configuration file is outdated (${cfg.version} < ${migrator.getLatestVersion()}). `
+ 'Trying to upgrade it...');
// Backup old config
const hash = crypto.createHash('sha256').update(cfgStr).digest('hex');
const backupPath = CONFIG_PATH + '.' + hash.substring(0, 16);
fs.writeFileSync(backupPath, cfgStr);
logger.info(`Current configurations are written up to ${backupPath}`);
// Migrate config
cfg = migrator.migrate(cfg);
// Save config
fs.writeFileSync(CONFIG_PATH, yaml.stringify(cfg));
logger.info(`${CONFIG_PATH} upgraded successfully.`);
const defaultValue = schema.getDefaultValue();
fs.writeFileSync(CONFIG_PATH + '.example', defaultValue.toYaml());
logger.info(`We also created an example at ${CONFIG_PATH + '.example'} for your reference.`);
}
}
// Check config file against schemas
const result = schema.validate(cfg);
if (result !== true) {
logger.warn('Configuration file failed one or more checks.');
logger.warn('Icarus may still run, but you will encounter excepted results.');
logger.warn('Here is some information for you to correct the configuration file.');
logger.warn(util.inspect(result));
}
} catch (e) {
logger.error(e);
logger.error(`Failed to load the configuration file ${CONFIG_PATH}.`);
logger.error('Please add \'--icarus-dont-check-config\' to your Hexo command if you');
logger.error('wish to skip the config file checking.');
process.exit(-1);
}
}
/**
* Register Hexo extensions
*/
logger.info('=== Patching Hexo ===');
/* global hexo */
require('../include/task/welcome');
require('../include/task/dependencies');
// require('../include/task/check_config');
require('../include/generator/categories')(hexo);
require('../include/generator/category')(hexo);
require('../include/generator/tags')(hexo);
@ -10,7 +122,9 @@ require('../include/filter/locals')(hexo);
require('../include/helper/cdn')(hexo);
require('../include/helper/page')(hexo);
// Fix large blog rendering OOM
/**
* Remove Hexo filters that could cause OOM
*/
const hooks = [
'after_render:html',
'after_post_render'
@ -24,8 +138,3 @@ hooks.forEach(hook => {
.filter(filter => filters.includes(filter.name))
.forEach(filter => hexo.extend.filter.unregister(hook, filter));
});
// Debug helper
hexo.extend.helper.register('console', function() {
console.log(arguments);
});