Full rewrite to make API faster and use less memory. Version 2.0.0-beta1

This commit is contained in:
Vjacheslav Trushkin 2018-11-06 20:09:52 +02:00
parent 2e48cffff8
commit 4ea82d2e4d
30 changed files with 2192 additions and 1560 deletions

2
.gitignore vendored
View File

@ -4,6 +4,4 @@
node_modules
config.json
*.log
_debug*.*
.ssl/ssl.*
git-repos

View File

@ -1,11 +1,8 @@
.idea
.git
.reload
.DS_Store
config.json
node_modules
npm-debug.log
tests
debug
_debug*.*
git-repos

431
app.js
View File

@ -1,337 +1,164 @@
/**
* Main file to run in Node.js
* This file is part of the @iconify/api package.
*
* (c) Vjacheslav Trushkin <cyberalien@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
"use strict";
/*
* Main stuff
*/
// Load required modules
const fs = require('fs'),
util = require('util'),
express = require('express');
// Express stuff
express = require('express'),
app = express(),
// Log uncaught exceptions to stderr
process.on('uncaughtException', function (err) {
console.error('Uncaught exception:', err);
});
// Configuration and version
version = JSON.parse(fs.readFileSync('package.json', 'utf8')).version,
// Create application
let app = {
root: __dirname
};
// Included files
Collections = require('./src/collections'),
// Query parser
parseQuery = require('./src/query');
// Configuration
let config = JSON.parse(fs.readFileSync(__dirname + '/config-default.json', 'utf8'));
/**
* Load config.json and config-default.json
*/
app.config = JSON.parse(fs.readFileSync(__dirname + '/config-default.json', 'utf8'));
try {
let customConfig = fs.readFileSync(__dirname + '/config.json', 'utf8');
if (typeof customConfig === 'string') {
try {
customConfig = JSON.parse(customConfig);
Object.keys(customConfig).forEach(key => {
if (typeof config[key] !== typeof customConfig[key]) {
if (typeof app.config[key] !== typeof customConfig[key]) {
return;
}
if (typeof config[key] === 'object') {
if (typeof app.config[key] === 'object') {
// merge object
Object.assign(config[key], customConfig[key]);
Object.assign(app.config[key], customConfig[key]);
} else {
// overwrite scalar variables
config[key] = customConfig[key];
app.config[key] = customConfig[key];
}
});
} catch (err) {
console.error('Error parsing config.json', err);
}
}
} catch (err) {
console.log('Missing config.json. Using default API configuration');
}
config._dir = __dirname;
// Enable logging module
require('./src/log')(config);
// Add logging and mail modules
app.mail = require('./src/mail').bind(this, app);
let log = require('./src/log');
app.log = log.bind(this, app, false);
app.error = log.bind(this, app, true);
app.logger = require('./src/logger').bind(this, app);
/**
* Validate configuration
*/
// Port
if (config['env-port'] && process.env.PORT) {
config.port = process.env.PORT;
if (app.config['env-port'] && process.env.PORT) {
app.config.port = process.env.PORT;
}
// Region file to easy identify server in CDN
if (!config['env-region'] && process.env.region) {
config.region = process.env.region;
if (!app.config['env-region'] && process.env.region) {
app.config.region = process.env.region;
}
if (config.region.length > 10 || !config.region.match(/^[a-z0-9_-]+$/i)) {
config.region = '';
config.log('Invalid value for region config variable.', 'config-region', true);
if (app.config.region.length > 10 || !app.config.region.match(/^[a-z0-9_-]+$/i)) {
app.config.region = '';
app.error('Invalid value for region config variable.');
}
// Reload secret key
if (config['reload-secret'] === '') {
if (app.config['reload-secret'] === '') {
// Add reload-secret to config.json to be able to run /reload?key=your-secret-key that will reload collections without restarting server
console.log('reload-secret configuration is empty. You will not be able to update all collections without restarting server.');
}
// Collections list
let collections = null,
loading = true,
anotherReload = false;
// Modules
let dirs = require('./src/dirs')(config),
sync = require('./src/sync')(config);
/**
* Load icons
*
* @param {boolean} firstLoad
* @param {object} [logger]
* @returns {Promise}
* Continue loading modules
*/
function loadIcons(firstLoad, logger) {
let newLogger = false;
if (!firstLoad && !logger) {
logger = new config.Logger('Reloading collections at ' + (new Date()).toString(), 90);
newLogger = true;
}
// Get version
app.version = JSON.parse(fs.readFileSync(__dirname + '/package.json', 'utf8')).version;
return new Promise((fulfill, reject) => {
function log(message) {
if (logger) {
logger.log(message, true);
} else {
console.log(message);
}
}
// Files helper
app.fs = require('./src/files')(app);
function getCollections() {
let t = Date.now(),
newCollections = new Collections(config);
// JSON loader
app.loadJSON = require('./src/json').bind(this, app);
console.log('Loading collections at ' + (new Date()).toString());
newCollections.reload(dirs.getRepos(), logger).then(() => {
log('Loaded in ' + (Date.now() - t) + 'ms');
if (newLogger) {
logger.send();
}
fulfill(newCollections);
}).catch(err => {
log('Error loading collections: ' + util.format(err));
if (logger) {
logger.send();
}
reject(err);
});
}
if (firstLoad && config.sync && config.sync['sync-on-startup']) {
// Synchronize repositories first
let promises = [];
dirs.keys().forEach(repo => {
if (sync.canSync(repo)) {
switch (config.sync['sync-on-startup']) {
case 'always':
break;
case 'never':
return;
case 'missing':
// Check if repository is missing
if (sync.time(repo)) {
return;
}
}
if (!logger) {
logger = new config.Logger('Synchronizing repositories at startup', 120);
}
logger.log('Adding repository "' + repo + '" to queue');
promises.push(sync.sync(repo, true, logger));
}
});
if (promises.length) {
log('Synchronizing repositories before starting...');
}
Promise.all(promises).then(() => {
getCollections();
}).catch(err => {
console.log(err);
getCollections();
});
} else {
getCollections();
}
});
}
function reloadIcons(firstLoad, logger) {
loading = true;
anotherReload = false;
loadIcons(false, logger).then(newCollections => {
collections = newCollections;
loading = false;
if (anotherReload) {
reloadIcons(false, logger);
}
}).catch(err => {
config.log('Fatal error loading collections:\n' + util.format(err), null, true);
if (logger && logger.active) {
logger.log('Fatal error loading collections:\n' + util.format(err));
}
loading = false;
if (anotherReload) {
reloadIcons(false, logger);
}
});
}
/**
* Send cache headers
*
* @param req
* @param res
*/
function cacheHeaders(req, res) {
if (
config.cache && config.cache.timeout &&
(req.get('Pragma') === void 0 || req.get('Pragma').indexOf('no-cache') === -1) &&
(req.get('Cache-Control') === void 0 || req.get('Cache-Control').indexOf('no-cache') === -1)
) {
res.set('Cache-Control', (config.cache.private ? 'private' : 'public') + ', max-age=' + config.cache.timeout + ', min-refresh=' + config.cache['min-refresh']);
if (!config.cache.private) {
res.set('Pragma', 'cache');
}
}
}
/**
* Send result object generated by query parser
*
* @param {object} result
* @param req
* @param res
*/
function sendResult(result, req, res) {
if (typeof result === 'number') {
res.sendStatus(result);
// Add directories storage
app.dirs = require('./src/dirs')(app);
if (!app.dirs.getRepos().length) {
console.error('No repositories found. Make sure either Iconify or custom repository is set in configuration.');
return;
}
// Send cache header
cacheHeaders(req, res);
// Collections
app.collections = {};
app.reload = require('./src/reload').bind(this, app);
// Check for download
if (result.filename !== void 0 && (req.query.download === '1' || req.query.download === 'true')) {
res.set('Content-Disposition', 'attachment; filename="' + result.filename + '"');
}
// Sync module
app.sync = require('./src/sync').bind(this, app);
// Send data
res.type(result.type).send(result.body);
}
// API request and response handlers
app.response = require('./src/response').bind(this, app);
app.iconsRequest = require('./src/request-icons').bind(this, app);
app.miscRequest = require('./src/request').bind(this, app);
/**
* Delay response
*
* @param {function} callback
* @param res
*/
function delayResponse(callback, res) {
// Attempt to parse query every 250ms for up to 10 seconds
let attempts = 0,
timer = setInterval(function() {
attempts ++;
if (collections === null) {
if (attempts > 40) {
clearInterval(timer);
res.sendStatus(503);
}
} else {
clearInterval(timer);
callback();
}
}, 250);
}
// Start application
require('./src/startup')(app).then(() => {
/**
* Parse request
*
* @param {string} prefix
* @param {string} query
* @param {string} ext
* @param {object} req
* @param {object} res
*/
function parseRequest(prefix, query, ext, req, res) {
function parse() {
let result = 404,
collection = collections.find(prefix);
if (collection !== null) {
result = parseQuery(collection, query, ext, req.query);
}
sendResult(result, req, res);
}
// Parse query
if (collections === null) {
// This means script is still loading
delayResponse(parse, res);
} else {
parse();
}
}
// Load icons
loadIcons(true).then(newCollections => {
collections = newCollections;
loading = false;
if (anotherReload) {
anotherReload = false;
setTimeout(() => {
reloadIcons(false);
}, 30000);
}
}).catch(err => {
config.log('Fatal error loading collections:\n' + util.format(err), null, true);
loading = false;
reloadIcons(true);
});
// Create HTTP server
app.server = express();
// Disable X-Powered-By header
app.disable('x-powered-by');
app.server.disable('x-powered-by');
// CORS
app.options('/*', (req, res) => {
if (config.cors) {
res.header('Access-Control-Allow-Origin', config.cors.origins);
res.header('Access-Control-Allow-Methods', config.cors.methods);
res.header('Access-Control-Allow-Headers', config.cors.headers);
res.header('Access-Control-Max-Age', config.cors.timeout);
app.server.options('/*', (req, res) => {
if (app.config.cors) {
res.header('Access-Control-Allow-Origin', app.config.cors.origins);
res.header('Access-Control-Allow-Methods', app.config.cors.methods);
res.header('Access-Control-Allow-Headers', app.config.cors.headers);
res.header('Access-Control-Max-Age', app.config.cors.timeout);
}
res.send(200);
});
// GET 3 part request
app.get(/^\/([a-z0-9-]+)\/([a-z0-9-]+)\.(js|json|svg)$/, (req, res) => {
app.server.get(/^\/([a-z0-9-]+)\/([a-z0-9-]+)\.(js|json|svg)$/, (req, res) => {
// prefix/icon.svg
// prefix/icons.json
parseRequest(req.params[0], req.params[1], req.params[2], req, res);
app.iconsRequest(req, res, req.params[0], req.params[1], req.params[2]);
});
// GET 2 part JS/JSON request
app.get(/^\/([a-z0-9-]+)\.(js|json)$/, (req, res) => {
app.server.get(/^\/([a-z0-9-]+)\.(js|json)$/, (req, res) => {
// prefix.json
parseRequest(req.params[0], 'icons', req.params[1], req, res);
app.iconsRequest(req, res, req.params[0], 'icons', req.params[1]);
});
// GET 2 part SVG request
app.get(/^\/([a-z0-9:-]+)\.svg$/, (req, res) => {
app.server.get(/^\/([a-z0-9:-]+)\.svg$/, (req, res) => {
let parts = req.params[0].split(':');
if (parts.length === 2) {
// prefix:icon.svg
parseRequest(parts[0], parts[1], 'svg', req, res);
app.iconsRequest(req, res, parts[0], parts[1], 'svg');
return;
}
@ -339,84 +166,42 @@ app.get(/^\/([a-z0-9:-]+)\.svg$/, (req, res) => {
parts = parts[0].split('-');
if (parts.length > 1) {
// prefix-icon.svg
parseRequest(parts.shift(), parts.join('-'), 'svg', req, res);
app.iconsRequest(req, res, parts.shift(), parts.join('-'), 'svg');
return;
}
}
res.sendStatus(404);
app.response(req, res, 404);
});
// Disable crawling
app.get('/robots.txt', (req, res) => {
res.type('text/plain').send('User-agent: *\nDisallow: /');
});
// Send robots.txt that disallows everything
app.server.get('/robots.txt', (req, res) => app.miscRequest(req, res, 'robots'));
app.server.post('/robots.txt', (req, res) => app.miscRequest(req, res, 'robots'));
// Debug information and AWS health check
app.get('/version', (req, res) => {
let body = 'Iconify API version ' + version + ' (Node';
if (config.region.length) {
body += ', ' + config.region;
}
body += ')';
res.send(body);
});
// API version information
app.server.get('/version', (req, res) => app.miscRequest(req, res, 'version'));
// Reload collections without restarting app
app.get('/reload', (req, res) => {
if (config['reload-secret'].length && req.query && req.query.key && req.query.key === config['reload-secret']) {
// Reload collections
process.nextTick(() => {
if (loading) {
anotherReload = true;
return;
}
reloadIcons(false);
});
}
app.server.get('/reload', (req, res) => app.miscRequest(req, res, 'reload'));
app.server.post('/reload', (req, res) => app.miscRequest(req, res, 'reload'));
// Send 200 response regardless of reload status, so visitor would not know if secret key was correct
// Testing should be done by checking new icons that should have been added by reload
res.sendStatus(200);
});
// Update collection without restarting app
let syncRequest = (req, res) => {
let repo = req.query.repo;
if (sync.canSync(repo) && sync.validKey(req.query.key)) {
if (config.sync['sync-delay']) {
console.log('Will start synchronizing repository "' + repo + '" in up to ' + config.sync['sync-delay'] + ' seconds...');
}
sync.sync(repo, false).then(result => {
if (result.result) {
// Refresh all icons
if (loading) {
anotherReload = true;
} else {
reloadIcons(false, result.logger);
}
}
}).catch(err => {
config.log('Error synchronizing repository "' + repo + '":\n' + util.format(err), 'sync-' + repo, true);
});
}
// Send 200 response regardless of reload status, so visitor would not know if secret key was correct
// Testing should be done by checking new icons that should have been added by reload
res.sendStatus(200);
};
app.get('/sync', syncRequest);
app.post('/sync', syncRequest);
// Get latest collection from Git repository
app.server.get('/sync', (req, res) => app.miscRequest(req, res, 'sync'));
app.server.post('/sync', (req, res) => app.miscRequest(req, res, 'sync'));
// Redirect home page
app.get('/', (req, res) => {
res.redirect(301, config['index-page']);
app.server.get('/', (req, res) => {
res.redirect(301, app.config['index-page']);
});
// Create server
app.listen(config.port, () => {
console.log('Listening on port ' + config.port);
app.server.listen(app.config.port, () => {
app.log('Listening on port ' + app.config.port);
});
module.exports = app;
}).catch(err => {
console.error(err);
});

View File

@ -7,6 +7,7 @@
"custom-icons-dir": "{dir}/json",
"serve-default-icons": true,
"index-page": "https://iconify.design/",
"json-loader": "parse",
"cache": {
"timeout": 604800,
"min-refresh": 604800,
@ -21,7 +22,7 @@
"sync": {
"sync-on-startup": "missing",
"sync-delay": 60,
"repeated-sync-delay": 600,
"repeated-sync-delay": 60,
"versions": "{dir}/git-repos/versions.json",
"storage": "{dir}/git-repos",
"git": "git clone {repo} --depth 1 --no-tags {target}",

0
license.txt Executable file → Normal file
View File

99
package-lock.json generated
View File

@ -1,17 +1,28 @@
{
"version": "1.0.0-rc2",
"version": "2.0.0-beta1",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@iconify/json": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@iconify/json/-/json-1.0.8.tgz",
"integrity": "sha512-I2953Mf++qOuo2hHVWvv8jiW+tyS8OAHixSr5UjIWAADzrIFLK5Q1FP28fBfmb2cMpeBPiV5oR+3KrkzoX0N6A=="
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@iconify/json/-/json-1.0.10.tgz",
"integrity": "sha512-W7rSBnh7tGlK5LS7IA9McFxm2wVSzx0K6SbT9oFwee3WXtEKRc1knZoAWfybCq+SheH2JTLc/p+QH8a/T+jIAA==",
"optional": true
},
"@iconify/json-tools": {
"version": "1.0.0-beta2",
"resolved": "https://registry.npmjs.org/@iconify/json-tools/-/json-tools-1.0.0-beta2.tgz",
"integrity": "sha512-pBCW9h0nEQyCuizYuiEtLsFZo0QeFZxA7ps7MWWFU1AF+ja8apuhjFbVfG2rex7utOriwp56B7VVGT3RiTRvOw=="
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@iconify/json-tools/-/json-tools-1.0.1.tgz",
"integrity": "sha512-A15I5GRny9gDZIfgu4nutUzdMsG0Bd2Ju6GbochRLu/ZSoMSH22L8mQrQdvdj0U/ydzR68mktUXrPUyG786Zlw=="
},
"JSONStream": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz",
"integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==",
"optional": true,
"requires": {
"jsonparse": "^1.2.0",
"through": ">=2.2.7 <3"
}
},
"accepts": {
"version": "1.3.5",
@ -162,6 +173,11 @@
"integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==",
"dev": true
},
"duplexer": {
"version": "0.1.1",
"resolved": "http://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz",
"integrity": "sha1-rOb/gIwc5mtX0ev5eXessCM0z8E="
},
"ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@ -188,6 +204,21 @@
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc="
},
"event-stream": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/event-stream/-/event-stream-4.0.1.tgz",
"integrity": "sha512-qACXdu/9VHPBzcyhdOWR5/IahhGMf0roTeZJfzz077GwylcDd90yOHLouhmv7GJ5XzPi6ekaQWd8AvPP2nOvpA==",
"optional": true,
"requires": {
"duplexer": "^0.1.1",
"from": "^0.1.7",
"map-stream": "0.0.7",
"pause-stream": "^0.0.11",
"split": "^1.0.1",
"stream-combiner": "^0.2.2",
"through": "^2.3.8"
}
},
"express": {
"version": "4.16.4",
"resolved": "https://registry.npmjs.org/express/-/express-4.16.4.tgz",
@ -249,6 +280,12 @@
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac="
},
"from": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz",
"integrity": "sha1-g8YK/Fi5xWmXAH7Rp2izqzA6RP4=",
"optional": true
},
"fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@ -332,6 +369,18 @@
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.8.0.tgz",
"integrity": "sha1-6qM9bd16zo9/b+DJygRA5wZzix4="
},
"jsonparse": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz",
"integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=",
"optional": true
},
"map-stream": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.0.7.tgz",
"integrity": "sha1-ih8HiW2CsQkmvTdEokIACfiJdKg=",
"optional": true
},
"media-typer": {
"version": "0.3.0",
"resolved": "http://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@ -432,7 +481,8 @@
"nodemailer": {
"version": "4.6.8",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-4.6.8.tgz",
"integrity": "sha512-A3s7EM/426OBIZbLHXq2KkgvmKbn2Xga4m4G+ZUA4IaZvG8PcZXrFh+2E4VaS2o+emhuUVRnzKN2YmpkXQ9qwA=="
"integrity": "sha512-A3s7EM/426OBIZbLHXq2KkgvmKbn2Xga4m4G+ZUA4IaZvG8PcZXrFh+2E4VaS2o+emhuUVRnzKN2YmpkXQ9qwA==",
"optional": true
},
"on-finished": {
"version": "2.3.0",
@ -473,6 +523,15 @@
"integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA=",
"dev": true
},
"pause-stream": {
"version": "0.0.11",
"resolved": "http://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz",
"integrity": "sha1-/lo0sMvOErWqaitAPuLnO2AvFEU=",
"optional": true,
"requires": {
"through": "~2.3"
}
},
"proxy-addr": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.4.tgz",
@ -549,11 +608,30 @@
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz",
"integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ=="
},
"split": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz",
"integrity": "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==",
"optional": true,
"requires": {
"through": "2"
}
},
"statuses": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-1.4.0.tgz",
"integrity": "sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew=="
},
"stream-combiner": {
"version": "0.2.2",
"resolved": "http://registry.npmjs.org/stream-combiner/-/stream-combiner-0.2.2.tgz",
"integrity": "sha1-rsjLrBd7Vrb0+kec7YwZEs7lKFg=",
"optional": true,
"requires": {
"duplexer": "~0.1.1",
"through": "~2.3.4"
}
},
"supports-color": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz",
@ -563,6 +641,11 @@
"has-flag": "^3.0.0"
}
},
"through": {
"version": "2.3.8",
"resolved": "http://registry.npmjs.org/through/-/through-2.3.8.tgz",
"integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU="
},
"type-detect": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",

View File

@ -1,5 +1,5 @@
{
"version": "1.0.0-rc2",
"version": "2.0.0-beta1",
"description": "Node.js version of api.iconify.design",
"private": true,
"main": "app.js",
@ -16,13 +16,17 @@
"url": "git+ssh://git@github.com/iconify-design/api.js.git"
},
"dependencies": {
"@iconify/json": "^1.0.8",
"@iconify/json-tools": "^1.0.0-beta2",
"express": "^4.16.4",
"nodemailer": "^4.6.8"
"express": "^4.16.4"
},
"devDependencies": {
"chai": "^4.2.0",
"mocha": "^5.2.0"
},
"optionalDependencies": {
"@iconify/json": "^1.0.8",
"JSONStream": "^1.3.5",
"event-stream": "^4.0.1",
"nodemailer": "^4.6.8"
}
}

View File

@ -1,316 +0,0 @@
/**
* This file is part of the @iconify/api package.
*
* (c) Vjacheslav Trushkin <cyberalien@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
"use strict";
const fs = require('fs');
const util = require('util');
const Collection = require('@iconify/json-tools').Collection;
/**
* Class to represent collection of collections
*/
class Collections {
/**
* Constructor
*
* @param {object} config Application configuration
*/
constructor(config) {
this._config = config;
this.items = {};
this._loadQueue = [];
}
/**
* Add directory to loading queue
*
* @param {string} dir
* @param {string} repo
* @param {object} [logger]
*/
addDirectory(dir, repo, logger) {
let message = 'Loading collections for repository "' + repo + '" from directory: ' + dir;
if (logger) {
logger.log(message, true)
} else {
console.log(message);
}
this._loadQueue.push({
type: 'dir',
dir: dir.slice(-1) === '/' ? dir.slice(0, dir.length - 1) : dir,
repo: repo
});
}
/**
* Add file to loading queue
*
* @param {string} filename
* @param {string} repo
*/
addFile(filename, repo) {
this._loadQueue.push({
type: 'file',
filename: filename,
repo: repo
});
}
/**
* Find collections
*
* @private
*/
_findCollections(repo, logger) {
return new Promise((fulfill, reject) => {
let config = this._config,
dirs = config._dirs,
iconsDir = dirs.iconsDir(repo);
if (iconsDir === '') {
// Nothing to add
fulfill();
return;
}
switch (repo) {
case 'iconify':
// Get collections.json
let filename = dirs.rootDir(repo) + '/collections.json';
fs.readFile(filename, 'utf8', (err, data) => {
if (err) {
let message = 'Error locating collections.json for Iconify default icons.\n' + util.format(err);
if (logger) {
logger.log(message);
}
reject(message);
return;
}
try {
data = JSON.parse(data);
} catch (err) {
let message = 'Error reading contents of' + filename + '\n' + util.format(err);
if (logger) {
logger.log(message);
}
reject(message);
return;
}
this.addDirectory(iconsDir, repo, logger);
this.info = data;
fulfill();
});
return;
default:
this.addDirectory(iconsDir, repo, logger);
fulfill();
}
});
}
/**
* Find all collections and loadQueue
*
* @param {Array} repos
* @param {object} [logger]
* @returns {Promise}
*/
reload(repos, logger) {
return new Promise((fulfill, reject) => {
let promises = repos.map(repo => this._findCollections(repo, logger));
Promise.all(promises).then(() => {
return this.loadQueue(logger);
}).then(() => {
fulfill(this);
}).catch(err => {
reject(err);
})
});
}
/**
* Load only one repository
*
* @param {string} repo Repository name
* @param {object} [logger]
* @returns {Promise}
*/
loadRepo(repo, logger) {
return new Promise((fulfill, reject) => {
Promise.all(this._findCollections(repo, logger)).then(() => {
return this.loadQueue(logger);
}).then(() => {
fulfill(this);
}).catch(err => {
reject(err);
})
});
}
/**
* Load queue
*
* Promise will never reject because single file should not break app,
* it will log failures instead
*
* @param {object} [logger]
* @returns {Promise}
*/
loadQueue(logger) {
return new Promise((fulfill, reject) => {
let promises = [];
this._loadQueue.forEach(item => {
switch (item.type) {
case 'dir':
promises.push(this._loadDir(item.dir, item.repo, logger));
break;
case 'file':
promises.push(this._loadFile(item.filename, item.repo, logger));
break;
}
});
Promise.all(promises).then(res => {
let total = 0;
res.forEach(count => {
if (typeof count === 'number') {
total += count;
}
});
let message = 'Loaded ' + total + ' icons';
if (logger) {
logger.log(message, true);
} else {
console.log(message);
}
fulfill(this);
}).catch(err => {
reject(err);
});
});
}
/**
* Load directory
*
* @param {string} dir
* @param {string} repo
* @param {object} [logger]
* @returns {Promise}
* @private
*/
_loadDir(dir, repo, logger) {
return new Promise((fulfill, reject) => {
fs.readdir(dir, (err, files) => {
if (err) {
this._config.log('Error reading directory: ' + dir + '\n' + util.format(err), 'collections-' + dir, true, logger);
fulfill(false);
} else {
let promises = [];
files.forEach(file => {
if (file.slice(-5) !== '.json') {
return;
}
promises.push(this._loadFile(dir + '/' + file, repo));
});
// Load all promises
Promise.all(promises).then(res => {
let total = 0;
res.forEach(count => {
if (typeof count === 'number') {
total += count;
}
});
fulfill(total);
}).catch(err => {
fulfill(false);
});
}
});
});
}
/**
* Load file
*
* @param {string} filename Full filename
* @param {string} repo
* @param {object} [logger]
* @returns {Promise}
*/
_loadFile(filename, repo, logger) {
return new Promise((fulfill, reject) => {
let file = filename.split('/').pop(),
fileParts = file.split('.');
if (fileParts.length !== 2) {
fulfill(false);
return;
}
let prefix = fileParts[0],
collection = new Collection();
collection.repo = repo;
collection.loadFromFileAsync(filename, prefix).then(result => {
collection = result;
if (collection.prefix() === false) {
this._config.log('Failed to load collection: ' + filename, 'collection-load-' + filename, true, logger);
fulfill(false);
return;
}
if (collection.prefix() !== prefix) {
this._config.log('Collection prefix does not match: ' + collection.prefix() + ' in file ' + filename, 'collection-prefix-' + filename, true, logger);
fulfill(false);
return;
}
let count = collection.listIcons(false).length;
if (!count) {
this._config.log('Collection is empty: ' + filename, 'collection-empty-' + filename, true, logger);
fulfill(false);
return;
}
this.items[prefix] = collection;
let message = 'Loaded collection ' + prefix + ' from ' + file + ' (' + count + ' icons)';
if (logger) {
logger.log(message, true);
} else {
console.log(message);
}
fulfill(count);
}).catch(err => {
fulfill(false);
});
});
}
/**
* Find collection
*
* @param {string} prefix
* @returns {Collection|null}
*/
find(prefix) {
return this.items[prefix] === void 0 ? null : this.items[prefix];
}
}
module.exports = Collections;

View File

@ -9,18 +9,37 @@
"use strict";
let config, _dirs;
const fs = require('fs');
let repos;
/**
* Directories storage.
* This module is responsible for storing and updating locations of collections
*
* @param app
* @returns {object}
*/
module.exports = app => {
let functions = {},
dirs = {},
custom = {},
repos = [],
storageDir = null,
versionsFile = null;
const functions = {
/**
* Get root directory of repository
*
* @param {string} repo
* @returns {string}
*/
rootDir: repo => _dirs[repo] === void 0 ? '' : _dirs[repo],
functions.rootDir = repo => dirs[repo] === void 0 ? '' : dirs[repo];
/**
* Get storage directory
*
* @return {string}
*/
functions.storageDir = () => storageDir;
/**
* Get icons directory
@ -28,7 +47,7 @@ const functions = {
* @param {string} repo
* @returns {string}
*/
iconsDir: repo => {
functions.iconsDir = repo => {
let dir;
switch (repo) {
@ -39,18 +58,24 @@ const functions = {
default:
return functions.rootDir(repo);
}
},
};
/**
* Set root directory for repository
*
* @param repo
* @param dir
* @param {string} repo
* @param {string} dir
*/
setRootDir: (repo, dir) => {
let extraKey = repo + '-dir';
if (config.sync && config.sync[extraKey] !== void 0 && config.sync[extraKey] !== '') {
let extra = config.sync[extraKey];
functions.setRootDir = (repo, dir) => {
// Append additional directory from config
let extra;
try {
extra = app.config.sync[repo + '-dir'];
} catch (err) {
extra = '';
}
if (extra !== void 0 && extra !== '') {
if (extra.slice(0, 1) !== '/') {
extra = '/' + extra;
}
@ -59,41 +84,107 @@ const functions = {
}
dir += extra;
}
_dirs[repo] = dir;
},
/**
* Get all repositories
*
* @returns {string[]}
*/
keys: () => Object.keys(_dirs),
/**
* Get all repositories
*
* @returns {string[]}
*/
getRepos: () => repos,
// Set directory
dirs[repo] = dir;
};
module.exports = appConfig => {
config = appConfig;
_dirs = {};
repos = [];
/**
* Set root directory for repository using repository time
*
* @param {string} repo
* @param {number} time
* @param {boolean} [save] True if new versions.json should be saved
*/
functions.setSynchronizedRepoDir = (repo, time, save) => {
let dir = storageDir + '/' + repo + '.' + time;
custom[repo] = time;
functions.setRootDir(repo, dir);
if (save === true) {
fs.writeFileSync(versionsFile, JSON.stringify(custom, null, 4), 'utf8');
}
};
/**
* Get all repositories
*
* @returns {string[]}
*/
functions.keys = () => Object.keys(dirs);
/**
* Get all repositories
*
* @returns {string[]}
*/
functions.getRepos = () => repos;
/**
* Check if repository has been synchronized
*
* @param {string} repo
* @return {boolean}
*/
functions.synchronized = repo => custom[repo] === true;
/**
* Initialize
*/
// Get synchronized repositories
let cached = {};
app.config.canSync = false;
try {
if (app.config.sync.versions && app.config.sync.storage) {
// Set storage directory and versions.json location
storageDir = app.config.sync.storage.replace('{dir}', app.root);
versionsFile = app.config.sync.versions.replace('{dir}', app.root);
app.config.canSync = true;
// Try getting latest repositories
cached = fs.readFileSync(versionsFile, 'utf8');
cached = JSON.parse(cached);
}
} catch (err) {
if (typeof cached !== 'object') {
cached = {};
}
}
if (storageDir !== null) {
try {
fs.mkdirSync(storageDir);
} catch (err) {
}
}
// Set default directories
if (config['serve-default-icons']) {
let icons = require('@iconify/json');
repos.push('iconify');
_dirs['iconify'] = icons.rootDir();
if (app.config['serve-default-icons']) {
let key = 'iconify';
if (cached && cached[key]) {
repos.push(key);
functions.setSynchronizedRepoDir(key, cached[key], false);
} else {
let icons;
try {
icons = require('@iconify/json');
repos.push(key);
dirs[key] = icons.rootDir();
} catch (err) {
app.error('Cannot load Iconify icons because @iconify/json package is not installed');
}
}
}
if (config['custom-icons-dir']) {
repos.push('custom');
_dirs['custom'] = config['custom-icons-dir'].replace('{dir}', config._dir);
if (app.config['custom-icons-dir']) {
let key = 'custom';
repos.push(key);
if (cached[key]) {
functions.setSynchronizedRepoDir(key, cached[key], false);
} else {
dirs[key] = app.config['custom-icons-dir'].replace('{dir}', app.root);
}
}
config._dirs = functions;
return functions;
};

102
src/files.js Normal file
View File

@ -0,0 +1,102 @@
/**
* This file is part of the @iconify/api package.
*
* (c) Vjacheslav Trushkin <cyberalien@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
"use strict";
const fs = require('fs');
const util = require('util');
const promiseEach = require('./promise');
let _app;
let functions = {
/**
* Remove file
*
* @param file
* @param options
* @return {Promise<any>}
*/
unlink: (file, options) => new Promise((fulfill, reject) => {
fs.unlink(file, err => {
if (err) {
_app.error('Error deleting file ' + file, Object.assign({
key: 'unlink-' + file
}, typeof options === 'object' ? options : {}));
}
fulfill();
})
}),
/**
* Recursively remove directory
*
* @param dir
* @param options
* @return {Promise<any>}
*/
rmdir: (dir, options) => new Promise((fulfill, reject) => {
options = typeof options === 'object' ? options : {};
function done() {
fs.rmdir(dir, err => {
if (err) {
_app.error('Error deleting directory ' + dir, Object.assign({
key: 'rmdir-' + dir
}, options));
}
fulfill();
});
}
fs.readdir(dir, (err, files) => {
if (err) {
// fulfill instead of rejecting
fulfill();
return;
}
let children = {};
files.forEach(file => {
let filename = dir + '/' + file,
stats = fs.lstatSync(filename);
if (stats.isDirectory()) {
children[filename] = true;
return;
}
if (stats.isFile() || stats.isSymbolicLink()) {
children[filename] = false;
}
});
promiseEach(Object.keys(children), file => {
if (children[file]) {
return functions.rmdir(file, options);
} else {
return functions.unlink(file, options);
}
}).then(() => {
done();
}).catch(err => {
_app.error('Error recursively removing directory ' + dir + '\n' + util.format(err), Object.assign({
key: 'rmdir-' + dir
}, options));
done();
});
});
})
};
module.exports = app => {
_app = app;
return functions;
};

143
src/json.js Normal file
View File

@ -0,0 +1,143 @@
/**
* This file is part of the @iconify/api package.
*
* (c) Vjacheslav Trushkin <cyberalien@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
"use strict";
const fs = require('fs');
const util = require('util');
// List of imported modules
let imported = {};
/**
* Import json file
*
* @param {object|string} app Application object or import method
* @param {string} file File to import
* @param {*} [hash] Hash of previously loaded file. If hashes match, load will be aborted
* @returns {Promise}
*/
module.exports = (app, file, hash) => new Promise((fulfill, reject) => {
let newHash = null,
result;
/**
* Parse json using JSONStream library
*
* @param JSONStream
* @param es
*/
function parseStream(JSONStream, es) {
let stream = fs.createReadStream(file, 'utf8'),
data;
stream.on('error', err => {
reject('Error importing ' + file + '\n' + util.format(err));
});
stream.on('end', () => {
result.data = data;
fulfill(result);
});
stream.pipe(JSONStream.parse(true)).pipe(es.mapSync(res => {
data = res;
}));
}
/**
* Common parser that uses synchronous functions to convert string to object
*
* @param method
*/
function syncParser(method) {
fs.readFile(file, 'utf8', (err, data) => {
if (err) {
reject('Error importing ' + file + '\n' + util.format(err));
return;
}
try {
switch (method) {
case 'eval':
data = Function('return ' + data)();
break;
default:
data = JSON.parse(data);
break;
}
} catch (err) {
reject('Error importing ' + file + '\n' + util.format(err));
return;
}
result.data = data;
fulfill(result);
});
}
// Get file information
fs.lstat(file, (err, stats) => {
if (!err) {
// Use file size instead of hash for faster loading
// assume json files are same when size is not changed
newHash = stats.size;
}
if (newHash && newHash === hash) {
fulfill({
changed: false,
hash: newHash
});
return;
}
result = {
changed: true,
hash: newHash
};
// Figure out which parser to use
// 'eval' is fastest, but its not safe
// 'json' is slower, but might crash when memory limit is low
// 'stream' is
let parser = 'parse';
try {
parser = typeof app === 'string' ? app : app.config['json-loader'];
} catch(err) {
}
switch (parser) {
case 'stream':
// use stream
if (imported.JSONStream === void 0) {
try {
imported.JSONStream = require('JSONStream');
imported.eventStream = require('event-stream');
} catch (err) {
console.error('Cannot use stream JSON parser because JSONStream or event-stream module is not available. Switching to default parser.');
imported.JSONStream = null;
}
}
if (imported.JSONStream === null) {
syncParser('json');
} else {
parseStream(imported.JSONStream, imported.eventStream);
}
break;
case 'eval':
// use Function()
syncParser('eval');
break;
default:
// use JSON.parse()
syncParser('json');
}
});
});

View File

@ -9,167 +9,116 @@
"use strict";
const nodemailer = require('nodemailer');
const util = require('util');
/**
* Inject logging function as config.log()
*
* @param config
*/
module.exports = config => {
if (config.mail && config.mail.active) {
let logged = {},
mailError = false,
throttled = false,
throttledData = [],
repeat = Math.max(config.mail.repeat, 15) * 60 * 1000; // convert minutes to ms, no less than 15 minutes
const defaultOptions = {
// True if message should be copied to stdout or stderr
log: true,
/**
* Send message
*
* @param message
*/
let sendMail = message => {
// Create transport
let transporter = nodemailer.createTransport(config.mail.transport);
// Logger object for event logging (combines multiple messages for one big log)
logger: null,
// Set data
let mailOptions = {
from: config.mail.from,
to: config.mail.to,
subject: config.mail.subject,
text: message
// Unique key. If set, message with that key will be sent by mail only once. Used to avoid sending too many emails
key: null,
// Console object
console: console
};
// Send email
transporter.sendMail(mailOptions, (err, info) => {
if (err) {
if (mailError === false) {
console.error('Error sending mail (this messages will not show up again on further email errors until app is restarted):');
console.error(err);
mailError = true;
}
}
});
// List of notices that are sent only once per session
let logged = {};
// List of throttled messages
let throttled = null;
/**
* Send throttled messages
*
* @param app
*/
const sendQueue = app => {
let text = throttled.join('\n\n- - - - - - - - - - -\n\n');
throttled = null;
app.mail(text);
};
/**
* Send messages queue
*/
let sendQueue = () => {
let mailOptions = throttledData.join('\n\n- - - - - - - - - - -\n\n');
throttled = false;
throttledData = [];
sendMail(mailOptions);
};
console.log('Logging to email is active. If you do not receive emails with errors, check configuration options.');
/**
* Log message. This function combines multiple logging methods, so it can be called only once instead of calling
* multiple log() functions.
*
* Message will be sent to console.log or console.error and sent by email.
*
* @param {object} app
* @param {boolean} error
* @param {string} message
* @param {string} [key] Unique key to identify logging message to avoid sending too many duplicate emails
* @param {boolean} [copyToConsole] True if log should be copied to console
* @param {object} [logger] Logger instance to copy message to
* @param {object|boolean} [options]
*/
config.log = (message, key, copyToConsole, logger) => {
if (copyToConsole) {
console.error('\x1b[31m' + message + '\x1b[0m');
}
if (logger) {
logger.log(message);
module.exports = (app, error, message, options) => {
options = Object.assign({}, defaultOptions, options === void 0 ? {} : (typeof options === 'boolean' ? {
log: options
}: options));
// Convert to test
if (typeof message !== 'string') {
message = util.format(message);
}
// Do not send same email more than once within "repeat" minutes
let time = Date.now() / repeat;
if (typeof key === 'string') {
if (logged[key] === time) {
// Get time stamp
let time = new Date();
time = (time.getUTCHours() > 10 ? '[' : '[0') + time.getUTCHours() + (time.getUTCMinutes() > 9 ? ':' : ':0') + time.getUTCMinutes() + (time.getUTCSeconds() > 9 ? ':' : ':0') + time.getUTCSeconds() + '] ';
// Copy message to console
if (options.log || !app.mail) {
if (error) {
options.console.error(time + '\x1b[31m' + message + '\x1b[0m');
} else {
options.console.log(time + message);
}
}
if (!app.mail) {
return;
}
logged[key] = time;
}
message = time + message;
// Throttle
throttledData.push(message);
if (config.mail.throttle) {
if (!throttled) {
throttled = true;
setTimeout(sendQueue, config.mail.throttle * 1000);
}
} else {
sendQueue();
}
};
/**
* Class for logging
*
* @type {Logger}
*/
config.Logger = class {
/**
* Create new logger
*
* @param {string} subject
* @param {number} [delay] Automatically send log after "delay" seconds
*/
constructor(subject, delay) {
this.active = true;
this.subject = subject;
this.messages = [subject];
if (delay) {
setTimeout(() => {
if (this.messages.length) {
this.send();
}
}, delay * 1000);
}
}
/**
* Log message
*
* @param {string} message
* @param {boolean} [sendToConsole]
*/
log(message, sendToConsole) {
if (sendToConsole === true) {
console.log(message);
}
this.messages.push(message);
}
/**
* Send logged messages
*/
send() {
if (!this.messages.length) {
// Copy to mail logger
if (options.logger) {
options.logger[error ? 'error' : 'log'](message);
return;
}
sendMail(this.messages.join("\n"));
this.messages = [];
}
};
} else {
console.log('Logging to email is not active.');
config.log = (message, key, copyToConsole, logger) => {
if (copyToConsole) {
console.error('\x1b[35m' + message + '\x1b[0m');
}
};
config.Logger = class {
constructor(subject) {
this.active = false;
}
log(message, sendToConsole) {
if (sendToConsole === true) {
console.log(message);
}
}
send() {}
};
// Send email if its a error and has not been sent before
if (!error) {
return;
}
if (options.key) {
let time = Date.now() / 1000,
repeat;
try {
repeat = app.config.mail.repeat;
} catch (err) {
repeat = 0;
}
if (logged[options.key]) {
if (!repeat || logged[options.key] > time) {
return;
}
}
logged[options.key] = repeat ? time + repeat : true;
}
// Add message to throttled data
if (throttled === null) {
throttled = [];
let delay;
try {
delay = app.config.mail.throttle;
} catch (err) {
delay = 60;
}
setTimeout(sendQueue.bind(null, app), delay * 1000)
}
throttled.push(message);
};

64
src/logger.js Normal file
View File

@ -0,0 +1,64 @@
/**
* This file is part of the @iconify/api package.
*
* (c) Vjacheslav Trushkin <cyberalien@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
"use strict";
class Logger {
constructor(app, subject, delay) {
this.app = app;
this.subject = subject;
this.messages = [];
this.delay = typeof delay === 'number' ? Math.min(Math.max(delay, 15), 300) : 60;
this.throttled = false;
}
send() {
if (this.messages.length) {
this.app.mail((this.subject ? this.subject + '\n\n' : '') + this.messages.join('\n'));
this.messages = [];
}
}
queue() {
if (!this.throttled) {
this.throttled = true;
setTimeout(() => {
this.send();
this.throttled = false;
}, this.delay * 1000);
}
}
log(message) {
this.messages.push(message);
if (!this.throttled) {
this.queue();
}
}
error(message) {
this.messages.push(message);
if (!this.throttled) {
this.queue();
}
}
}
/**
* Bulk message logger. It combines several messages and puts them in one email.
*
* Usage: logger = app.logger('subject', 60)
* then use it as "logger" parameter in app.log or app.error calls
*
* @param {object} app API application
* @param {string|null} [subject] Log subject
* @param {number} [delay] Delay of first set of messages
* @return {object}
*/
module.exports = (app, subject, delay) => new Logger(app, subject, delay);

50
src/mail.js Normal file
View File

@ -0,0 +1,50 @@
/**
* This file is part of the @iconify/api package.
*
* (c) Vjacheslav Trushkin <cyberalien@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
"use strict";
let nodemailer;
module.exports = (app, message) => {
if (nodemailer === null) {
return;
}
let config;
try {
config = app.config.mail;
if (!config.active) {
return;
}
if (nodemailer === void 0) {
nodemailer = require('nodemailer');
}
} catch (err) {
nodemailer = null;
return;
}
let transporter = nodemailer.createTransport(config.transport);
// Set data
let mailOptions = {
from: config.from,
to: config.to,
subject: config.subject,
text: message
};
// Send email
transporter.sendMail(mailOptions, (err, info) => {
if (err) {
console.error('Error sending mail:', err);
}
});
};

View File

@ -17,16 +17,18 @@
* @returns {Promise<any>}
*/
module.exports = (list, callback) => new Promise((fulfill, reject) => {
let results = [];
let results = [],
index = -1,
total = list.length;
function next() {
let item = list.shift();
if (item === void 0) {
index ++;
if (index === total) {
fulfill(results);
return;
}
let promise = callback(item);
let promise = callback(list[index]);
if (promise === null) {
// skip
next();

View File

@ -1,97 +0,0 @@
/**
* This file is part of the @iconify/api package.
*
* (c) Vjacheslav Trushkin <cyberalien@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
"use strict";
const SVG = require('@iconify/json-tools').SVG;
/**
* Generate SVG string
*
* @param {object} icon
* @param {object} [params]
* @returns {string}
*/
function generateSVG(icon, params) {
let svg = new SVG(icon);
return svg.getSVG(params);
}
/**
* Regexp for checking callback attribute
*
* @type {RegExp}
* @private
*/
const _callbackMatch = /^[a-z0-9_.]+$/i;
/**
* Generate data for query
*
* @param {Collection} collection
* @param {string} query Query string after last / without extension
* @param {string} ext Extension
* @param {object} params Parameters
* @returns {number|object}
*/
module.exports = (collection, query, ext, params) => {
switch (ext) {
case 'svg':
// Generate SVG
// query = icon name
let icon = collection.getIconData(query);
if (icon === null) {
return 404;
}
return {
filename: query + '.svg',
type: 'image/svg+xml; charset=utf-8',
body: generateSVG(icon, params)
};
case 'js':
case 'json':
if (query !== 'icons' || typeof params.icons !== 'string') {
return 404;
}
let result = collection.getIcons(params.icons.split(','));
if (result === null || !Object.keys(result.icons).length) {
return 404;
}
if (result.aliases !== void 0 && !Object.keys(result.aliases).length) {
delete result.aliases;
}
result = JSON.stringify(result);
if (ext === 'js') {
let callback;
if (params.callback !== void 0) {
callback = params.callback;
if (!callback.match(_callbackMatch)) {
return 400;
}
} else {
callback = 'SimpleSVG._loaderCallback';
}
return {
type: 'application/javascript; charset=utf-8',
body: callback + '(' + result + ')'
};
}
return {
type: 'application/json; charset=utf-8',
body: result
};
default:
return 404;
}
};

343
src/reload.js Normal file
View File

@ -0,0 +1,343 @@
/**
* This file is part of the @iconify/api package.
*
* (c) Vjacheslav Trushkin <cyberalien@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
"use strict";
const fs = require('fs');
const util = require('util');
const promiseEach = require('./promise');
const Collection = require('@iconify/json-tools').Collection;
const defaultOptions = {
// Logger instance
logger: null
};
let repoItems = {},
collectionRepos = {},
hashes = {},
nextReload = 0;
class Loader {
constructor(app, repos, options) {
this.app = app;
this.repos = repos;
this.options = options;
this.updated = [];
this.start = Date.now();
this.reloadInfo = false;
}
/**
* Remove root directory from filename
*
* @param {string} filename
* @return {string}
* @private
*/
_prettyFile(filename) {
return filename.slice(0, this.app.root.length) === this.app.root ? filename.slice(this.app.root.length + 1) : filename;
}
/**
* Find collections
*
* @return {Promise<Array>}
*/
findCollections() {
return new Promise((fulfill, reject) => {
promiseEach(this.repos, repo => new Promise((fulfill, reject) => {
// Get directory
let dir = this.app.dirs.iconsDir(repo);
if (dir === '') {
reject('Missing directory for repository "' + repo + '"');
return;
}
// Find all files
fs.readdir(dir, (err, files) => {
let items = [];
if (err) {
reject('Error reading directory: ' + this._prettyFile(dir) + '\n' + util.format(err));
return;
}
files.forEach(file => {
if (file.slice(-5) !== '.json') {
return;
}
items.push({
repo: repo,
file: file,
filename: dir + '/' + file,
prefix: file.slice(0, file.length - 5)
});
});
fulfill(items);
});
})).then(results => {
let items = [];
results.forEach(result => {
result.forEach(item => {
if (collectionRepos[item.prefix] === void 0) {
// New collection. Add it to list
if (repoItems[item.repo] === void 0) {
repoItems[item.repo] = [item.prefix];
} else {
repoItems[item.repo].push(item.prefix);
}
items.push(item);
return;
}
if (collectionRepos[item.prefix] !== item.repo) {
// Conflict: same prefix in multiple repositories
this.app.error('Collection "' + item.prefix + '" is found in multiple repositories. Ignoring json file from ' + item.repo + ', using file from ' + collectionRepos[item.prefix], Object.assign({
key: 'json-duplicate/' + item.repo + '/' + item.prefix
}, this.options));
return;
}
// Everything is fine
items.push(item);
});
});
fulfill(items);
}).catch(err => {
reject(err);
});
});
}
/**
* Load collections
*
* @param {Array} items
* @return {Promise<any>}
*/
loadCollections(items) {
return new Promise((fulfill, reject) => {
let total;
// Load all files
promiseEach(items, item => new Promise((fulfill, reject) => {
let collection;
// Load JSON file
this.app.loadJSON(item.filename, hashes[item.prefix] === void 0 ? null : hashes[item.prefix]).then(result => {
if (!result.changed) {
// Nothing to do
fulfill(true);
return;
}
return this.loadCollection(item, result);
}).then(result => {
collection = result;
// Run post-load function if there is one
if (this.app.postLoadCollection) {
return this.app.postLoadCollection(collection, this.options);
}
}).then(() => {
fulfill(collection);
}).catch(err => {
reject('Error loading json file: ' + this._prettyFile(item.filename) + '\n' + util.format(err));
});
})).then(collections => {
let loaded = 0,
skipped = 0;
total = 0;
collections.forEach(collection => {
if (collection === true) {
skipped ++;
return;
}
loaded ++;
let count = Object.keys(collection.items.icons).length,
prefix = collection.prefix();
this.app.log('Loaded collection ' + prefix + ' from ' + collection.filename + ' (' + count + ' icons)', this.options);
total += count;
this.app.collections[prefix] = collection;
});
this.app.log('Loaded ' + total + ' icons from ' + loaded + (loaded > 1 ? ' collections ' : ' collection ') + (skipped ? '(no changes in ' + skipped + (skipped > 1 ? ' collections) ' : ' collection) ') : '') + 'in ' + (Date.now() - this.start) / 1000 + ' seconds.', this.options);
if (this.reloadInfo) {
return this.getInfo();
}
}).then(() => {
fulfill(total);
}).catch(err => {
reject(err);
});
});
}
/**
* Get Iconify collections data
*
* @return {Promise<any>}
*/
getInfo() {
return new Promise((fulfill, reject) => {
let filename = this.app.dirs.rootDir('iconify') + '/collections.json';
fs.readFile(filename, 'utf8', (err, data) => {
if (err) {
reject('Error locating collections.json for Iconify default icons.\n' + util.format(err));
return;
}
try {
data = JSON.parse(data);
} catch (err) {
reject('Error reading contents of' + filename + '\n' + util.format(err));
return;
}
this.app.info = data;
fulfill();
});
});
}
/**
* Load one collection
*
* @param {object} item findCollections() result
* @param {object} data loadJSON() result
* @return {Promise<Collection>}
*/
loadCollection(item, data) {
return new Promise((fulfill, reject) => {
let collection = new Collection();
if (!collection.loadJSON(data.data, item.prefix)) {
delete data.data;
reject('Error loading collection "' + item.prefix + '" from repository "' + item.repo + '": error parsing JSON');
return;
}
delete data.data;
let prefix = collection.prefix();
if (prefix !== item.prefix) {
delete collection.items;
reject('Error loading collection "' + item.prefix + '" from repository "' + item.repo + '": invalid prefix in JSON file: ' + prefix);
return;
}
collection.filename = this._prettyFile(item.filename);
collection.repo = item.repo;
hashes[item.prefix] = data.hash;
this.updated.push(item.prefix);
if (item.repo === 'iconify') {
this.reloadInfo = true;
}
fulfill(collection);
});
}
}
/**
* Reload collections
*
* @param {object} app
* @param {Array|string|boolean} [repos] Repositories to reload
* @param {object} [options]
*/
module.exports = (app, repos, options) => new Promise((fulfill, reject) => {
// Options
options = Object.assign({}, defaultOptions, typeof options === 'object' ? options : {});
// Get list of repositories to reload
let availableRepos = app.dirs.getRepos();
// noinspection FallThroughInSwitchStatementJS
switch (typeof repos) {
case 'string':
if (availableRepos.indexOf(repos) === -1) {
reject('Cannot update repository: ' + repos);
return;
}
repos = [repos];
break;
case 'object':
if (repos instanceof Array) {
let newList = [];
repos.forEach(repo => {
if (availableRepos.indexOf(repo) !== -1) {
newList.push(repo);
}
});
repos = newList;
break;
}
case 'boolean':
if (repos === false) {
// false -> reload was called by /reload url
// limit such reloads to 1 per 30 seconds
if (Date.now() < nextReload) {
fulfill(false);
return;
}
}
default:
repos = availableRepos.slice(0);
}
if (!repos.length) {
reject('No available repositories to update.');
return;
}
if (app.reloading === true) {
reject('Reload is already in progress.');
return;
}
// Create logger if its missing
if (!options.logger) {
options.logger = app.logger('Loading repositories', 30);
}
// Create loader instance and do stuff
let loader = new Loader(app, repos, options),
count;
app.reloading = true;
loader.findCollections().then(items => {
return loader.loadCollections(items);
}).then(total => {
count = total;
// Run post-load function if there is one
if (app.postReload) {
return app.postReload(loader.updated, options);
}
}).then(() => {
// Do not allow /reload for 30 seconds
nextReload = Date.now() + 30000;
// Done
fulfill({
icons: count,
updated: loader.updated
});
app.reloading = false;
}).catch(err => {
reject(err);
app.reloading = false;
});
});

95
src/request-icons.js Normal file
View File

@ -0,0 +1,95 @@
/**
* This file is part of the @iconify/api package.
*
* (c) Vjacheslav Trushkin <cyberalien@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
"use strict";
const SVG = require('@iconify/json-tools').SVG;
/**
* Generate SVG string
*
* @param {object} icon
* @param {object} [params]
* @returns {string}
*/
function generateSVG(icon, params) {
let svg = new SVG(icon);
return svg.getSVG(params);
}
/**
* Regexp for checking callback attribute
*
* @type {RegExp}
* @private
*/
const _callbackMatch = /^[a-z0-9_.]+$/i;
/**
* Parse request
*
* @param {object} app
* @param {object} req
* @param {object} res
* @param {string} prefix Collection prefix
* @param {string} query Query
* @param {string} ext Extension
*/
module.exports = (app, req, res, prefix, query, ext) => {
if (app.collections[prefix] === void 0) {
app.response(req, res, 404);
return;
}
let collection = app.collections[prefix],
params = req.query;
let parse = () => {
switch (ext) {
case 'svg':
// Generate SVG
// query = icon name
let icon = collection.getIconData(query);
if (icon === null) {
return 404;
}
return {
filename: query + '.svg',
type: 'image/svg+xml; charset=utf-8',
body: generateSVG(icon, params)
};
case 'js':
case 'json':
if (query !== 'icons' || typeof params.icons !== 'string') {
return 404;
}
let result = collection.getIcons(params.icons.split(','));
if (result === null || !Object.keys(result.icons).length) {
return 404;
}
if (result.aliases !== void 0 && !Object.keys(result.aliases).length) {
delete result.aliases;
}
return {
js: ext === 'js',
defaultCallback: 'SimpleSVG._loaderCallback',
data: result
};
default:
return 404;
}
};
app.response(req, res, parse());
};

80
src/request.js Normal file
View File

@ -0,0 +1,80 @@
/**
* This file is part of the @iconify/api package.
*
* (c) Vjacheslav Trushkin <cyberalien@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
"use strict";
/**
* Parse request
*
* @param {object} app
* @param {object} req
* @param {object} res
* @param {string} query Query
*/
module.exports = (app, req, res, query) => {
let body;
switch (query) {
case 'version':
body = 'Iconify API version ' + app.version + ' (Node';
if (app.config.region.length) {
body += ', ' + app.config.region;
}
body += ')';
app.response(req, res, {
type: 'text/plain',
body: body
});
return;
case 'robots':
app.response(req, res, {
type: 'text/plain',
body: 'User-agent: *\nDisallow: /'
});
return;
case 'reload':
// Send 200 response regardless of success to prevent visitors from guessing key
app.response(req, res, 200);
// Do stuff
if (app.config['reload-secret'].length && req.query && req.query.key && req.query.key === app.config['reload-secret'] && !app.reloading) {
process.nextTick(() => {
app.reload(false).then(() => {
}).catch(err => {
app.error('Error reloading collections:\n' + util.format(err));
});
});
}
return;
case 'sync':
// Send 200 response regardless of success to prevent visitors from guessing key
app.response(req, res, 200);
let repo = req.query.repo;
if (!app.config.canSync || !app.config.sync[repo] || !app.config.sync.git || !app.config.sync.secret) {
return;
}
let key = req.query.key;
if (key !== app.config.sync.secret) {
return;
}
process.nextTick(() => {
app.sync(repo).then(() => {
}).catch(err => {
app.error(err);
});
});
return;
}
};

87
src/response.js Normal file
View File

@ -0,0 +1,87 @@
/**
* This file is part of the @iconify/api package.
*
* (c) Vjacheslav Trushkin <cyberalien@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
"use strict";
/**
* Regexp for checking callback attribute
*
* @type {RegExp}
* @private
*/
const callbackMatch = /^[a-z0-9_.]+$/i;
/**
* Send response
*
* @param app
* @param req
* @param res
* @param result
*/
module.exports = (app, req, res, result) => {
if (typeof result === 'number') {
// Send error
res.sendStatus(result);
return;
}
// Convert JSON(P) response
if (result.body === void 0 && result.data !== void 0) {
if (typeof result.data === 'object') {
result.body = (req.query.pretty === '1' || req.query.pretty === 'true') ? JSON.stringify(result.data, null, 4) : JSON.stringify(result.data);
}
if (result.js === void 0) {
result.js = req.query.callback !== void 0;
}
if (result.js === true) {
let callback;
if (result.callback === void 0 && req.query.callback !== void 0) {
callback = req.query.callback;
if (!callback.match(callbackMatch)) {
// Invalid callback
res.sendStatus(400);
return;
}
} else {
callback = result.callback === void 0 ? result.defaultCallback : result.callback;
if (callback === void 0) {
res.sendStatus(400);
return;
}
}
result.body = callback + '(' + result.body + ');';
result.type = 'application/javascript; charset=utf-8';
} else {
result.type = 'application/json; charset=utf-8';
}
}
// Send cache header
if (
app.config.cache && app.config.cache.timeout &&
(req.get('Pragma') === void 0 || req.get('Pragma').indexOf('no-cache') === -1) &&
(req.get('Cache-Control') === void 0 || req.get('Cache-Control').indexOf('no-cache') === -1)
) {
res.set('Cache-Control', (app.config.cache.private ? 'private' : 'public') + ', max-age=' + app.config.cache.timeout + ', min-refresh=' + app.config.cache['min-refresh']);
if (!app.config.cache.private) {
res.set('Pragma', 'cache');
}
}
// Check for download
if (result.filename !== void 0 && (req.query.download === '1' || req.query.download === 'true')) {
res.set('Content-Disposition', 'attachment; filename="' + result.filename + '"');
}
// Send data
res.type(result.type).send(result.body);
};

82
src/startup.js Normal file
View File

@ -0,0 +1,82 @@
/**
* This file is part of the @iconify/api package.
*
* (c) Vjacheslav Trushkin <cyberalien@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
"use strict";
const util = require('util');
const promiseEach = require('./promise');
module.exports = app => new Promise((fulfill, reject) => {
let actions = [],
logger = app.logger('Starting API...', 60),
start = Date.now();
// Check for repositories to synchronize
if (app.config.canSync) {
switch (app.config['sync-on-startup']) {
case 'always':
case 'missing':
app.dirs.getRepos().forEach(repo => {
if (app.sync[repo] && (
app.config['sync-on-startup'] === 'always' || !app.dirs.synchronized(repo)
)) {
actions.push({
action: 'sync',
repo: repo
});
}
});
break;
}
}
// Load icons
actions.push({
action: 'load'
});
// Parse each promise
promiseEach(actions, action => new Promise((fulfill, reject) => {
switch (action.action) {
case 'load':
// Load icons
app.reload(null, {
logger: logger
}).then(() => {
if (!Object.keys(app.collections).length) {
reject('No collections were found.');
} else {
fulfill();
}
}).catch(err => {
reject('Error loading collections: ' + util.format(err));
});
return;
case 'sync':
// Load icons
app.sync(action.repo, {
noDelay: true,
reload: false,
logger: logger
}).then(res => {
fulfill();
}).catch(err => {
reject('Error synchronizing repository "' + repo + '": ' + util.format(err));
});
return;
}
})).then(() => {
logger.log('\nStart up process completed in ' + (Date.now() - start) / 1000 + ' seconds.');
fulfill();
}).catch(err => {
logger.error('\nStart up process failed!\n\n' + util.format(err));
reject(err);
});
});

View File

@ -9,484 +9,155 @@
"use strict";
const fs = require('fs'),
util = require('util'),
child_process = require('child_process'),
promiseQueue = require('./promise');
const fs = require('fs');
const util = require('util');
const child_process = require('child_process');
let synchronized = {},
active = false,
cleaning = false,
synchronizing = {},
reSync = {},
syncQueue = {};
let config, dirs, repos, _baseDir, _repoDir, _versionsFile;
/**
* Start synchronization
*
* @param repo
* @param logger
*/
const startSync = (repo, logger) => {
if (syncQueue[repo] === void 0) {
return;
}
function done(success) {
if (success) {
logger.log('Saved latest version of repository "' + repo + '" to ' + targetDir, true);
synchronized[repo] = t;
functions.saveVersions();
dirs.setRootDir(repo, targetDir);
setTimeout(functions.cleanup, 300000);
}
synchronizing[repo] = false;
syncQueue[repo].forEach((done, index) => {
if (index > 0) {
// Send false to all promises except first one to avoid loading collections several times
done({
result: false,
logger: logger
});
} else {
done({
result: success,
logger: logger
});
}
});
}
logger.log('Synchronizing repository "' + repo + '" ...', true);
synchronizing[repo] = true;
let t = Date.now(),
targetDir = _baseDir + '/' + repo + '.' + t,
repoURL = config.sync[repo],
cmd = config.sync.git.replace('{target}', '"' + targetDir + '"').replace('{repo}', '"' + repoURL + '"');
reSync[repo] = false;
child_process.exec(cmd, {
cwd: _baseDir,
env: process.env,
uid: process.getuid()
}, (error, stdout, stderr) => {
if (error) {
if (logger.active) {
logger.log('Error executing git:' + util.format(error), true);
logger.send();
} else {
config.log('Error executing git:' + util.format(error), cmd, true);
}
done(false);
return;
}
if (reSync[repo]) {
// Another sync event was asked while cloning repository. Do it again
startSync(repo);
return;
}
done(true);
});
const defaultOptions = {
logger: null,
noDelay: false,
reload: true
};
/**
* Remove file
*
* @param {string} file
* @returns {Promise<any>}
*/
const removeFile = file => new Promise((fulfill, reject) => {
fs.unlink(file, err => {
if (err) {
config.log('Error deleting file ' + file, file, false);
}
fulfill();
})
});
let active = {},
queued = {};
/**
* Remove directory with sub-directories and files
*
* @param {string} dir
* @returns {Promise<any>}
*/
const removeDir = dir => new Promise((fulfill, reject) => {
function done() {
fs.rmdir(dir, err => {
if (err) {
config.log('Error deleting directory ' + dir, dir, false);
}
fulfill();
});
class Sync {
constructor(app, repo, options) {
this.app = app;
this.repo = repo;
this.options = options;
}
fs.readdir(dir, (err, files) => {
if (err) {
// fulfill instead of rejecting
fulfill();
return;
}
sync() {
return new Promise((fulfill, reject) => {
this.app.log('Synchronizing repository "' + this.repo + '"...', this.options);
let children = {};
let time = Date.now(),
root = this.app.dirs.storageDir(),
targetDir = root + '/' + this.repo + '.' + time,
repoURL = this.app.config.sync[this.repo],
cmd = this.app.config.sync.git.replace('{target}', '"' + targetDir + '"').replace('{repo}', '"' + repoURL + '"');
files.forEach(file => {
let filename = dir + '/' + file,
stats = fs.lstatSync(filename);
if (stats.isDirectory()) {
children[filename] = true;
return;
}
if (stats.isFile() || stats.isSymbolicLink()) {
children[filename] = false;
}
});
promiseQueue(Object.keys(children), file => {
if (children[file]) {
return removeDir(file);
} else {
return removeFile(file);
}
}).then(() => {
done();
}).catch(err => {
config.log('Error recursively removing directory ' + dir + '\n' + util.format(err), 'rmdir-' + dir, true);
done();
});
});
});
/**
* Remove directory with sub-directories and files
*
* @param {string} dir
* @returns {Promise<any>}
*/
const rmDir = dir => new Promise((fulfill, reject) => {
function oldMethod() {
removeDir(dir).then(() => {
fulfill();
}).catch(err => {
reject(err);
});
}
if (!config.sync.rm) {
oldMethod();
return;
}
fs.lstat(dir, (err, stats) => {
if (err) {
if (err.code && err.code === 'ENOENT') {
// No such file/directory
fulfill();
return;
}
// Unknown error
config.log('Error checking directory ' + dir + '\n' + util.format(err), 'rmd-' + dir, true);
fulfill(false);
return;
}
// Attempt to remove using exec()
let cmd = config.sync.rm.replace('{dir}', '"' + dir + '"');
child_process.exec(cmd, {
cwd: _baseDir,
cwd: root,
env: process.env,
uid: process.getuid()
}, (error, stdout, stderr) => {
if (error) {
// rmdir didn't work? Attempt to remove each file
oldMethod();
reject('Error executing git:' + util.format(error));
return;
}
// Make sure directory is removed
fs.lstat(dir, (err, stats) => {
if (err && err.code && err.code === 'ENOENT') {
fulfill();
return;
// Done. Set new directory and reload collections
this.app.dirs.setSynchronizedRepoDir(this.repo, time, true);
fulfill(true);
});
});
}
oldMethod();
});
});
});
});
/**
* Exported functions
*
* @type {object}
*/
const functions = {
/**
* Get root directory of repository
*
* @param {string} repo
* @returns {string|null}
*/
root: repo => synchronized[repo] ? _baseDir + '/' + repo + '.' + synchronized[repo] : null,
/**
* Check if repository can be synchronized
*
* @param {string} repo
* @returns {boolean}
*/
canSync: repo => active && synchronized[repo] !== void 0,
/**
* Get last synchronization time
*
* @param {string} repo
* @returns {number}
*/
time: repo => active && synchronized[repo] !== void 0 ? synchronized[repo] : 0,
/**
* Check if key is valid
*
* @param {string} key
* @returns {boolean}
*/
validKey: key => typeof key === 'string' && key.length && key === config.sync.secret,
/**
* Save versions.json
*/
saveVersions: () => {
let data = {};
Object.keys(synchronized).forEach(repo => {
if (synchronized[repo]) {
data[repo] = synchronized[repo];
}
});
fs.writeFile(_versionsFile, JSON.stringify(data, null, 4), 'utf8', err => {
if (err) {
config.error('Error saving versions.json\n' + util.format(err), 'version-' + _versionsFile, true);
}
});
},
/**
* Synchronize repository
*
* @param {string} repo
* @param {boolean} [immediate]
* @param {*} [logger]
* @returns {Promise<any>}
* @param app
* @param repo
* @param options
* @param fulfill
* @param reject
*/
sync: (repo, immediate, logger) => new Promise((fulfill, reject) => {
let finished = false,
attempts = 0;
static sync(app, repo, options, fulfill, reject) {
active[repo] = true;
queued[repo] = false;
function done(result) {
if (finished) {
let sync = new Sync(app, repo, options);
sync.sync(fulfill, reject).then(() => {
active[repo] = false;
if (queued[repo]) {
// Retry
let retryDelay;
try {
retryDelay = app.config.sync['repeated-sync-delay'];
} catch (err) {
retryDelay = 60;
}
app.log('Repository "' + repo + '" has finished synchronizing, but there is another sync request queued. Will do another sync in ' + retryDelay + ' seconds.', options);
setTimeout(() => {
Sync.sync(app, repo, options, fulfill, reject);
}, retryDelay * 1000);
return;
}
finished = true;
fulfill(result);
}
function nextAttempt() {
if (finished) {
return;
}
if (synchronizing[repo]) {
// Another repository is still being synchronized?
logger.log('Cannot start repository synchronization because sync is already in progress.', true);
attempts ++;
if (attempts > 3) {
done(false);
// Done
app.log('Completed synchronization of repository "' + repo + '".', options);
if (options.reload && !queued[repo]) {
app.reload(repo, options).then(() => {
fulfill(true);
}).catch(err => {
reject(err);
});
} else {
setTimeout(nextAttempt, config.sync['repeated-sync-delay'] * 1000);
fulfill(true);
}
}).catch(err => {
reject(err);
})
}
}
module.exports = (app, repo, options) => new Promise((fulfill, reject) => {
// Options
options = Object.assign({}, defaultOptions, typeof options !== 'object' ? options : {});
// Check if synchronization is disabled
if (!app.config.canSync || !app.config.sync[repo] || !app.config.sync.git) {
reject('Synchronization is disabled.');
return;
}
// Start synchronizing
startSync(repo, logger);
}
if (!logger) {
logger = new config.Logger('Synchronizing repository "' + repo + '" at ' + (new Date()).toString(), (immediate ? 0 : config.sync['sync-delay']) + 90);
}
if (!active) {
logger.log('Cannot synchronize repositories.');
logger.send();
reject('Cannot synchronize repositories.');
// Check if repository sync is already in queue
if (queued[repo]) {
app.log('Repository "' + repo + '" is already in synchronization queue.', options);
fulfill(false);
return;
}
let delay, retryDelay;
try {
delay = app.config.sync['sync-delay'];
retryDelay = app.config.sync['repeated-sync-delay'];
} catch (err) {
delay = 60;
retryDelay = 60;
}
if (options.noDelay) {
delay = 0;
}
// Add to queue
reSync[repo] = true;
if (syncQueue[repo] === void 0) {
syncQueue[repo] = [done];
queued[repo] = true;
// Check if repository is already being synchronized
if (active[repo]) {
app.log('Repository "' + repo + '" is already being synchronized. Will do another sync ' + retryDelay + ' seconds after previous sync completes.', options);
fulfill(false);
return;
}
// Create logger if its missing
if (!options.logger) {
options.logger = app.logger('Synchronizing repository: ' + repo, delay + 15);
}
// Start time
if (!delay) {
Sync.sync(app, repo, options, fulfill, reject);
} else {
syncQueue[repo].push(done);
app.log('Repository "' + repo + '" will start synchronizing in ' + delay + ' seconds.', options);
setTimeout(() => {
Sync.sync(app, repo, options, fulfill, reject);
}, delay * 1000);
}
if (synchronizing[repo]) {
// Wait until previous sync operation is over
logger.log('Another sync is in progress. Waiting for ' + config.sync['repeated-sync-delay'] + ' seconds before next attempt.');
setTimeout(nextAttempt, config.sync['repeated-sync-delay'] * 1000);
attempts ++;
} else if (immediate === true) {
// Start immediately
nextAttempt();
} else {
// Wait a bit to avoid multiple synchronizations
logger.log('Waiting for ' + config.sync['sync-delay'] + ' before starting synchronization.');
setTimeout(nextAttempt, config.sync['sync-delay'] * 1000);
}
}),
/**
* Remove old files
*/
cleanup: () => {
if (cleaning) {
return;
}
cleaning = true;
fs.readdir(_baseDir, (err, files) => {
if (err) {
cleaning = false;
return;
}
let dirs = [];
files.forEach(file => {
let parts = file.split('.');
if (parts.length !== 2 || synchronized[parts[0]] === void 0) {
return;
}
let repo = parts.shift(),
time = parseInt(parts.shift());
if (time > (synchronized[repo] - 3600 * 1000)) {
// wait 1 hour before deleting old repository
return;
}
dirs.push(_baseDir + '/' + file);
});
if (!dirs.length) {
cleaning = false;
return;
}
console.log('Cleaning up old repositories...');
// Delete all directories, but only 1 at a time to reduce loadQueue
promiseQueue(dirs, dir => rmDir(dir)).then(() => {
cleaning = false;
}).catch(err => {
config.log('Error cleaning up old files:\n' + util.format(err), 'cleanup', true);
cleaning = false;
});
});
}
};
/**
* Initialize. Find active repositories
*/
function init() {
if (!config.sync || !config.sync.versions || !config.sync.storage) {
// Synchronization is inactive
return;
}
if (!config.sync.secret) {
// Cannot sync without secret word
console.log('Repositories synchronization is not possible because "secret" is empty. Check config.md for details.');
return;
}
// Check active repositories
repos.forEach(repo => {
if (!config.sync[repo]) {
return;
}
synchronized[repo] = 0;
synchronizing[repo] = false;
});
if (!Object.keys(synchronized).length) {
// Nothing was found
console.log('Repositories synchronization is not possible because no active repositories were found. Check config.md for details.');
return;
}
// Try to create base directory
_baseDir = config.sync.storage.replace('{dir}', config._dir);
try {
fs.mkdirSync(_baseDir);
} catch (err) {
}
// Check for versions.json
_versionsFile = config.sync.versions.replace('{dir}', config._dir);
active = true;
let data;
try {
data = fs.readFileSync(_versionsFile, 'utf8');
data = JSON.parse(data);
} catch (err) {
// Nothing to parse
return;
}
Object.keys(data).forEach(key => {
let dir;
if (synchronized[key] === void 0) {
return;
}
dir = _baseDir + '/' + key + '.' + data[key];
try {
let stat = fs.lstatSync(dir);
if (stat && stat.isDirectory()) {
// Found directory
synchronized[key] = data[key];
dirs.setRootDir(key, dir);
console.log('Icons will be loaded from ' + dir + ' instead of default location.');
return;
}
} catch (err) {
}
config.log('Error loading latest collections: directory does not exist: ' + dir, 'missing-' + dir, true);
});
setTimeout(functions.cleanup, 60000);
}
module.exports = appConfig => {
config = appConfig;
dirs = config._dirs;
repos = dirs.getRepos();
init();
config._sync = functions;
return functions;
};

82
tests/fixtures/test1-optimized.json vendored Normal file
View File

@ -0,0 +1,82 @@
{
"prefix": "fa",
"icons": {
"arrow-circle-left": {
"body": "<path d=\"M1280 832V704q0-26-19-45t-45-19H714l189-189q19-19 19-45t-19-45l-91-91q-18-18-45-18t-45 18L360 632l-91 91q-18 18-18 45t18 45l91 91 362 362q18 18 45 18t45-18l91-91q18-18 18-45t-18-45L714 896h502q26 0 45-19t19-45zm256-64q0 209-103 385.5T1153.5 1433 768 1536t-385.5-103T103 1153.5 0 768t103-385.5T382.5 103 768 0t385.5 103T1433 382.5 1536 768z\" fill=\"currentColor\"/>"
},
"arrow-circle-up": {
"body": "<path d=\"M1284 767q0-27-18-45L904 360l-91-91q-18-18-45-18t-45 18l-91 91-362 362q-18 18-18 45t18 45l91 91q18 18 45 18t45-18l189-189v502q0 26 19 45t45 19h128q26 0 45-19t19-45V714l189 189q19 19 45 19t45-19l91-91q18-18 18-45zm252 1q0 209-103 385.5T1153.5 1433 768 1536t-385.5-103T103 1153.5 0 768t103-385.5T382.5 103 768 0t385.5 103T1433 382.5 1536 768z\" fill=\"currentColor\"/>"
},
"arrow-up": {
"body": "<path d=\"M1579 779q0 51-37 90l-75 75q-38 38-91 38-54 0-90-38L992 651v704q0 52-37.5 84.5T864 1472H736q-53 0-90.5-32.5T608 1355V651L314 944q-36 38-90 38t-90-38l-75-75q-38-38-38-90 0-53 38-91L710 37q35-37 90-37 54 0 91 37l651 651q37 39 37 91z\" fill=\"currentColor\"/>",
"width": 1600,
"height": 1472,
"inlineTop": -192
},
"arrow-left": {
"body": "<path d=\"M1472 736v128q0 53-32.5 90.5T1355 992H651l293 294q38 36 38 90t-38 90l-75 76q-37 37-90 37-52 0-91-37L37 890Q0 853 0 800q0-52 37-91L688 59q38-38 91-38 52 0 90 38l75 74q38 38 38 91t-38 91L651 608h704q52 0 84.5 37.5T1472 736z\" fill=\"currentColor\"/>",
"width": 1472,
"height": 1600,
"inlineTop": -160
},
"arrows": {
"body": "<path d=\"M1792 896q0 26-19 45l-256 256q-19 19-45 19t-45-19-19-45v-128h-384v384h128q26 0 45 19t19 45-19 45l-256 256q-19 19-45 19t-45-19l-256-256q-19-19-19-45t19-45 45-19h128v-384H384v128q0 26-19 45t-45 19-45-19L19 941Q0 922 0 896t19-45l256-256q19-19 45-19t45 19 19 45v128h384V384H640q-26 0-45-19t-19-45 19-45L851 19q19-19 45-19t45 19l256 256q19 19 19 45t-19 45-45 19h-128v384h384V640q0-26 19-45t45-19 45 19l256 256q19 19 19 45z\" fill=\"currentColor\"/>",
"width": 1792,
"height": 1792,
"inlineTop": 0
},
"arrows-alt": {
"body": "<path d=\"M1283 413L928 768l355 355 144-144q29-31 70-14 39 17 39 59v448q0 26-19 45t-45 19h-448q-42 0-59-40-17-39 14-69l144-144-355-355-355 355 144 144q31 30 14 69-17 40-59 40H64q-26 0-45-19t-19-45v-448q0-42 40-59 39-17 69 14l144 144 355-355-355-355-144 144q-19 19-45 19-12 0-24-5-40-17-40-59V64q0-26 19-45T64 0h448q42 0 59 40 17 39-14 69L413 253l355 355 355-355-144-144q-31-30-14-69 17-40 59-40h448q26 0 45 19t19 45v448q0 42-39 59-13 5-25 5-26 0-45-19z\" fill=\"currentColor\"/>"
},
"arrows-h": {
"body": "<path d=\"M1792 640q0 26-19 45l-256 256q-19 19-45 19t-45-19-19-45V768H384v128q0 26-19 45t-45 19-45-19L19 685Q0 666 0 640t19-45l256-256q19-19 45-19t45 19 19 45v128h1024V384q0-26 19-45t45-19 45 19l256 256q19 19 19 45z\" fill=\"currentColor\"/>",
"width": 1792,
"height": 1280,
"inlineTop": -256
},
"arrows-v": {
"body": "<path d=\"M640 320q0 26-19 45t-45 19H448v1024h128q26 0 45 19t19 45-19 45l-256 256q-19 19-45 19t-45-19L19 1517q-19-19-19-45t19-45 45-19h128V384H64q-26 0-45-19T0 320t19-45L275 19q19-19 45-19t45 19l256 256q19 19 19 45z\" fill=\"currentColor\"/>",
"width": 640,
"height": 1792,
"inlineTop": 0
},
"assistive-listening-systems": {
"body": "<path d=\"M128 1728q0 26-19 45t-45 19-45-19-19-45 19-45 45-19 45 19 19 45zm192-192q0 26-19 45t-45 19-45-19-19-45 19-45 45-19 45 19 19 45zm45-365l256 256-90 90-256-256zm339-19q0 26-19 45t-45 19-45-19-19-45 19-45 45-19 45 19 19 45zm707-320q0 59-11.5 108.5T1362 1034t-44 67.5-53 64.5q-31 35-45.5 54t-33.5 50-26.5 64-7.5 74q0 159-112.5 271.5T768 1792q-26 0-45-19t-19-45 19-45 45-19q106 0 181-75t75-181q0-57 11.5-105.5t37-91 43.5-66.5 52-63q40-46 59.5-72t37.5-74.5 18-103.5q0-185-131.5-316.5T835 384 518.5 515.5 387 832q0 26-19 45t-45 19-45-19-19-45q0-117 45.5-223.5t123-184 184-123T835 256t223.5 45.5 184 123 123 184T1411 832zM896 960q0 26-19 45t-45 19-45-19-19-45 19-45 45-19 45 19 19 45zm288-128q0 26-19 45t-45 19-45-19-19-45q0-93-65.5-158.5T832 608q-92 0-158 65.5T608 832q0 26-19 45t-45 19-45-19-19-45q0-146 103-249t249-103 249 103 103 249zm394-289q10 25-1 49t-36 34q-9 4-23 4-19 0-35.5-11t-23.5-30q-68-178-224-295-21-16-25-42t12-47q17-21 43-25t47 12q183 137 266 351zm210-81q9 25-1.5 49t-35.5 34q-11 4-23 4-44 0-60-41-92-238-297-393-22-16-25.5-42t12.5-47q16-22 42-25.5t47 12.5q235 175 341 449z\" fill=\"currentColor\"/>",
"width": 1792,
"height": 1792,
"inlineTop": 0
},
"asterisk": {
"body": "<path d=\"M1386 922q46 26 59.5 77.5T1433 1097l-64 110q-26 46-77.5 59.5T1194 1254l-266-153v307q0 52-38 90t-90 38H672q-52 0-90-38t-38-90v-307l-266 153q-46 26-97.5 12.5T103 1207l-64-110q-26-46-12.5-97.5T86 922l266-154L86 614q-46-26-59.5-77.5T39 439l64-110q26-46 77.5-59.5T278 282l266 153V128q0-52 38-90t90-38h128q52 0 90 38t38 90v307l266-153q46-26 97.5-12.5T1369 329l64 110q26 46 12.5 97.5T1386 614l-266 154z\" fill=\"currentColor\"/>",
"width": 1472
},
"at": {
"body": "<path d=\"M972 647q0-108-53.5-169T771 417q-63 0-124 30.5T537 532t-79.5 137T427 849q0 112 53.5 173t150.5 61q96 0 176-66.5t122.5-166T972 647zm564 121q0 111-37 197t-98.5 135-131.5 74.5-145 27.5q-6 0-15.5.5t-16.5.5q-95 0-142-53-28-33-33-83-52 66-131.5 110T612 1221q-161 0-249.5-95.5T274 856q0-157 66-290t179-210.5T765 278q87 0 155 35.5t106 99.5l2-19 11-56q1-6 5.5-12t9.5-6h118q5 0 13 11 5 5 3 16l-120 614q-5 24-5 48 0 39 12.5 52t44.5 13q28-1 57-5.5t73-24 77-50 57-89.5 24-137q0-292-174-466T768 128q-130 0-248.5 51t-204 136.5-136.5 204T128 768t51 248.5 136.5 204 204 136.5 248.5 51q228 0 405-144 11-9 24-8t21 12l41 49q8 12 7 24-2 13-12 22-102 83-227.5 128T768 1536q-156 0-298-61t-245-164-164-245T0 768t61-298 164-245T470 61 768 0q344 0 556 212t212 556z\" fill=\"currentColor\"/>"
},
"audio-description": {
"body": "<path d=\"M504 738h171l-1-265zm1026-99q0-87-50.5-140T1333 446h-54v388h52q91 0 145-57t54-138zM956 262l1 756q0 14-9.5 24t-23.5 10H708q-14 0-23.5-10t-9.5-24v-62H384l-55 81q-10 15-28 15H34q-21 0-30.5-18T7 999l556-757q9-14 27-14h332q14 0 24 10t10 24zm827 377q0 193-125.5 303T1333 1052h-270q-14 0-24-10t-10-24V262q0-14 10-24t24-10h268q200 0 326 109t126 302zm156 1q0 11-.5 29t-8 71.5-21.5 102-44.5 108T1791 1053h-51q38-45 66.5-104.5t41.5-112 21-98 9-72.5l1-27q0-8-.5-22.5t-7.5-60-20-91.5-41-111.5-66-124.5h43q41 47 72 107t45.5 111.5 23 96T1938 614zm184 0q0 11-.5 29t-8 71.5-21.5 102-45 108-74 102.5h-51q38-45 66.5-104.5t41.5-112 21-98 9-72.5l1-27q0-8-.5-22.5t-7.5-60-19.5-91.5-40.5-111.5-66-124.5h43q41 47 72 107t45.5 111.5 23 96T2122 614zm181 0q0 11-.5 29t-8 71.5-21.5 102-44.5 108T2156 1053h-51q38-45 66-104.5t41-112 21-98 9-72.5l1-27q0-8-.5-22.5t-7.5-60-19.5-91.5-40.5-111.5-66-124.5h43q41 47 72 107t45.5 111.5 23 96 9.5 70.5z\" fill=\"currentColor\"/>",
"width": 2304,
"height": 1280,
"inlineTop": -256
}
},
"aliases": {
"arrow-circle-right": {
"parent": "arrow-circle-left",
"hFlip": true
},
"arrow-down": {
"parent": "arrow-up",
"vFlip": true
},
"arrow-right": {
"parent": "arrow-left",
"hFlip": true
}
},
"width": 1536,
"height": 1536,
"inlineHeight": 1792,
"inlineTop": -128,
"verticalAlign": -0.143
}

115
tests/fixtures/test1.json vendored Normal file
View File

@ -0,0 +1,115 @@
{
"prefix": "fa",
"icons": {
"arrow-circle-left": {
"body": "<path d=\"M1280 832V704q0-26-19-45t-45-19H714l189-189q19-19 19-45t-19-45l-91-91q-18-18-45-18t-45 18L360 632l-91 91q-18 18-18 45t18 45l91 91 362 362q18 18 45 18t45-18l91-91q18-18 18-45t-18-45L714 896h502q26 0 45-19t19-45zm256-64q0 209-103 385.5T1153.5 1433 768 1536t-385.5-103T103 1153.5 0 768t103-385.5T382.5 103 768 0t385.5 103T1433 382.5 1536 768z\" fill=\"currentColor\"\/>",
"width": 1536,
"height": 1536,
"inlineHeight": 1792,
"inlineTop": -128,
"verticalAlign": -0.143
},
"arrow-circle-up": {
"body": "<path d=\"M1284 767q0-27-18-45L904 360l-91-91q-18-18-45-18t-45 18l-91 91-362 362q-18 18-18 45t18 45l91 91q18 18 45 18t45-18l189-189v502q0 26 19 45t45 19h128q26 0 45-19t19-45V714l189 189q19 19 45 19t45-19l91-91q18-18 18-45zm252 1q0 209-103 385.5T1153.5 1433 768 1536t-385.5-103T103 1153.5 0 768t103-385.5T382.5 103 768 0t385.5 103T1433 382.5 1536 768z\" fill=\"currentColor\"\/>",
"width": 1536,
"height": 1536,
"inlineHeight": 1792,
"inlineTop": -128,
"verticalAlign": -0.143
},
"arrow-up": {
"body": "<path d=\"M1579 779q0 51-37 90l-75 75q-38 38-91 38-54 0-90-38L992 651v704q0 52-37.5 84.5T864 1472H736q-53 0-90.5-32.5T608 1355V651L314 944q-36 38-90 38t-90-38l-75-75q-38-38-38-90 0-53 38-91L710 37q35-37 90-37 54 0 91 37l651 651q37 39 37 91z\" fill=\"currentColor\"\/>",
"width": 1600,
"height": 1472,
"inlineTop": -192,
"inlineHeight": 1792,
"verticalAlign": -0.143
},
"arrow-left": {
"body": "<path d=\"M1472 736v128q0 53-32.5 90.5T1355 992H651l293 294q38 36 38 90t-38 90l-75 76q-37 37-90 37-52 0-91-37L37 890Q0 853 0 800q0-52 37-91L688 59q38-38 91-38 52 0 90 38l75 74q38 38 38 91t-38 91L651 608h704q52 0 84.5 37.5T1472 736z\" fill=\"currentColor\"\/>",
"width": 1472,
"height": 1600,
"inlineTop": -160,
"inlineHeight": 1792,
"verticalAlign": -0.143
},
"arrows": {
"body": "<path d=\"M1792 896q0 26-19 45l-256 256q-19 19-45 19t-45-19-19-45v-128h-384v384h128q26 0 45 19t19 45-19 45l-256 256q-19 19-45 19t-45-19l-256-256q-19-19-19-45t19-45 45-19h128v-384H384v128q0 26-19 45t-45 19-45-19L19 941Q0 922 0 896t19-45l256-256q19-19 45-19t45 19 19 45v128h384V384H640q-26 0-45-19t-19-45 19-45L851 19q19-19 45-19t45 19l256 256q19 19 19 45t-19 45-45 19h-128v384h384V640q0-26 19-45t45-19 45 19l256 256q19 19 19 45z\" fill=\"currentColor\"\/>",
"width": 1792,
"height": 1792,
"inlineTop": 0,
"inlineHeight": 1792,
"verticalAlign": -0.143
},
"arrows-alt": {
"body": "<path d=\"M1283 413L928 768l355 355 144-144q29-31 70-14 39 17 39 59v448q0 26-19 45t-45 19h-448q-42 0-59-40-17-39 14-69l144-144-355-355-355 355 144 144q31 30 14 69-17 40-59 40H64q-26 0-45-19t-19-45v-448q0-42 40-59 39-17 69 14l144 144 355-355-355-355-144 144q-19 19-45 19-12 0-24-5-40-17-40-59V64q0-26 19-45T64 0h448q42 0 59 40 17 39-14 69L413 253l355 355 355-355-144-144q-31-30-14-69 17-40 59-40h448q26 0 45 19t19 45v448q0 42-39 59-13 5-25 5-26 0-45-19z\" fill=\"currentColor\"\/>",
"width": 1536,
"height": 1536,
"inlineHeight": 1792,
"inlineTop": -128,
"verticalAlign": -0.143
},
"arrows-h": {
"body": "<path d=\"M1792 640q0 26-19 45l-256 256q-19 19-45 19t-45-19-19-45V768H384v128q0 26-19 45t-45 19-45-19L19 685Q0 666 0 640t19-45l256-256q19-19 45-19t45 19 19 45v128h1024V384q0-26 19-45t45-19 45 19l256 256q19 19 19 45z\" fill=\"currentColor\"\/>",
"width": 1792,
"height": 1280,
"inlineTop": -256,
"inlineHeight": 1792,
"verticalAlign": -0.143
},
"arrows-v": {
"body": "<path d=\"M640 320q0 26-19 45t-45 19H448v1024h128q26 0 45 19t19 45-19 45l-256 256q-19 19-45 19t-45-19L19 1517q-19-19-19-45t19-45 45-19h128V384H64q-26 0-45-19T0 320t19-45L275 19q19-19 45-19t45 19l256 256q19 19 19 45z\" fill=\"currentColor\"\/>",
"width": 640,
"height": 1792,
"inlineTop": 0,
"inlineHeight": 1792,
"verticalAlign": -0.143
},
"assistive-listening-systems": {
"body": "<path d=\"M128 1728q0 26-19 45t-45 19-45-19-19-45 19-45 45-19 45 19 19 45zm192-192q0 26-19 45t-45 19-45-19-19-45 19-45 45-19 45 19 19 45zm45-365l256 256-90 90-256-256zm339-19q0 26-19 45t-45 19-45-19-19-45 19-45 45-19 45 19 19 45zm707-320q0 59-11.5 108.5T1362 1034t-44 67.5-53 64.5q-31 35-45.5 54t-33.5 50-26.5 64-7.5 74q0 159-112.5 271.5T768 1792q-26 0-45-19t-19-45 19-45 45-19q106 0 181-75t75-181q0-57 11.5-105.5t37-91 43.5-66.5 52-63q40-46 59.5-72t37.5-74.5 18-103.5q0-185-131.5-316.5T835 384 518.5 515.5 387 832q0 26-19 45t-45 19-45-19-19-45q0-117 45.5-223.5t123-184 184-123T835 256t223.5 45.5 184 123 123 184T1411 832zM896 960q0 26-19 45t-45 19-45-19-19-45 19-45 45-19 45 19 19 45zm288-128q0 26-19 45t-45 19-45-19-19-45q0-93-65.5-158.5T832 608q-92 0-158 65.5T608 832q0 26-19 45t-45 19-45-19-19-45q0-146 103-249t249-103 249 103 103 249zm394-289q10 25-1 49t-36 34q-9 4-23 4-19 0-35.5-11t-23.5-30q-68-178-224-295-21-16-25-42t12-47q17-21 43-25t47 12q183 137 266 351zm210-81q9 25-1.5 49t-35.5 34q-11 4-23 4-44 0-60-41-92-238-297-393-22-16-25.5-42t12.5-47q16-22 42-25.5t47 12.5q235 175 341 449z\" fill=\"currentColor\"\/>",
"width": 1792,
"height": 1792,
"inlineTop": 0,
"inlineHeight": 1792,
"verticalAlign": -0.143
},
"asterisk": {
"body": "<path d=\"M1386 922q46 26 59.5 77.5T1433 1097l-64 110q-26 46-77.5 59.5T1194 1254l-266-153v307q0 52-38 90t-90 38H672q-52 0-90-38t-38-90v-307l-266 153q-46 26-97.5 12.5T103 1207l-64-110q-26-46-12.5-97.5T86 922l266-154L86 614q-46-26-59.5-77.5T39 439l64-110q26-46 77.5-59.5T278 282l266 153V128q0-52 38-90t90-38h128q52 0 90 38t38 90v307l266-153q46-26 97.5-12.5T1369 329l64 110q26 46 12.5 97.5T1386 614l-266 154z\" fill=\"currentColor\"\/>",
"width": 1472,
"height": 1536,
"inlineHeight": 1792,
"inlineTop": -128,
"verticalAlign": -0.143
},
"at": {
"body": "<path d=\"M972 647q0-108-53.5-169T771 417q-63 0-124 30.5T537 532t-79.5 137T427 849q0 112 53.5 173t150.5 61q96 0 176-66.5t122.5-166T972 647zm564 121q0 111-37 197t-98.5 135-131.5 74.5-145 27.5q-6 0-15.5.5t-16.5.5q-95 0-142-53-28-33-33-83-52 66-131.5 110T612 1221q-161 0-249.5-95.5T274 856q0-157 66-290t179-210.5T765 278q87 0 155 35.5t106 99.5l2-19 11-56q1-6 5.5-12t9.5-6h118q5 0 13 11 5 5 3 16l-120 614q-5 24-5 48 0 39 12.5 52t44.5 13q28-1 57-5.5t73-24 77-50 57-89.5 24-137q0-292-174-466T768 128q-130 0-248.5 51t-204 136.5-136.5 204T128 768t51 248.5 136.5 204 204 136.5 248.5 51q228 0 405-144 11-9 24-8t21 12l41 49q8 12 7 24-2 13-12 22-102 83-227.5 128T768 1536q-156 0-298-61t-245-164-164-245T0 768t61-298 164-245T470 61 768 0q344 0 556 212t212 556z\" fill=\"currentColor\"\/>",
"width": 1536,
"height": 1536,
"inlineHeight": 1792,
"inlineTop": -128,
"verticalAlign": -0.143
},
"audio-description": {
"body": "<path d=\"M504 738h171l-1-265zm1026-99q0-87-50.5-140T1333 446h-54v388h52q91 0 145-57t54-138zM956 262l1 756q0 14-9.5 24t-23.5 10H708q-14 0-23.5-10t-9.5-24v-62H384l-55 81q-10 15-28 15H34q-21 0-30.5-18T7 999l556-757q9-14 27-14h332q14 0 24 10t10 24zm827 377q0 193-125.5 303T1333 1052h-270q-14 0-24-10t-10-24V262q0-14 10-24t24-10h268q200 0 326 109t126 302zm156 1q0 11-.5 29t-8 71.5-21.5 102-44.5 108T1791 1053h-51q38-45 66.5-104.5t41.5-112 21-98 9-72.5l1-27q0-8-.5-22.5t-7.5-60-20-91.5-41-111.5-66-124.5h43q41 47 72 107t45.5 111.5 23 96T1938 614zm184 0q0 11-.5 29t-8 71.5-21.5 102-45 108-74 102.5h-51q38-45 66.5-104.5t41.5-112 21-98 9-72.5l1-27q0-8-.5-22.5t-7.5-60-19.5-91.5-40.5-111.5-66-124.5h43q41 47 72 107t45.5 111.5 23 96T2122 614zm181 0q0 11-.5 29t-8 71.5-21.5 102-44.5 108T2156 1053h-51q38-45 66-104.5t41-112 21-98 9-72.5l1-27q0-8-.5-22.5t-7.5-60-19.5-91.5-40.5-111.5-66-124.5h43q41 47 72 107t45.5 111.5 23 96 9.5 70.5z\" fill=\"currentColor\"\/>",
"width": 2304,
"height": 1280,
"inlineTop": -256,
"inlineHeight": 1792,
"verticalAlign": -0.143
}
},
"aliases": {
"arrow-circle-right": {
"parent": "arrow-circle-left",
"hFlip": true
},
"arrow-down": {
"parent": "arrow-up",
"vFlip": true
},
"arrow-right": {
"parent": "arrow-left",
"hFlip": true
}
}
}

53
tests/load_json_test.js Normal file
View File

@ -0,0 +1,53 @@
"use strict";
(() => {
const loadJSON = require('../src/json');
const fs = require('fs'),
chai = require('chai'),
expect = chai.expect,
should = chai.should();
describe('Loading JSON file', () => {
const filename = __dirname + '/fixtures/test1.json',
expectedResult = JSON.parse(fs.readFileSync(filename, 'utf8'));
// Check if stream method is available
let testStream;
try {
require('JSONStream');
require('event-stream');
testStream = true;
} catch (err) {
testStream = false;
}
// Test with each method
['json', 'eval', 'stream'].forEach(method => {
it(method, function(done) {
if (method === 'stream' && !testStream) {
this.skip();
return;
}
// Load file
loadJSON(method, filename).then(result => {
expect(result.changed).to.be.equal(true);
expect(result.data).to.be.eql(expectedResult);
// Load file with same hash
loadJSON(method, filename, result.hash).then(result2 => {
expect(result2.changed).to.be.equal(false);
expect(result2.hash).to.be.equal(result.hash);
done();
}).catch(err => {
done(err);
});
}).catch(err => {
done(err);
});
});
});
});
})();

144
tests/log_test.js Normal file
View File

@ -0,0 +1,144 @@
"use strict";
(() => {
const log = require('../src/log');
const chai = require('chai'),
expect = chai.expect,
should = chai.should();
describe('Logging messages', () => {
it ('logging error to console and mail', done => {
let logged = {
log: false,
mail: false
};
let fakeApp = {
mail: message => {
expect(message.indexOf(expectedMessage) !== false).to.be.equal(true);
logged.mail = true;
},
logger: () => {
done('logger() should not have been called');
},
config: {
mail: {
throttle: 0.2
}
}
};
let expectedMessage = 'This is a test';
log(fakeApp, true, expectedMessage, {
console: {
error: message => {
expect(message.indexOf(expectedMessage) !== false).to.be.equal(true);
logged.log = true;
},
log: message => {
done('console.log should not have been called');
}
}
});
setTimeout(() => {
expect(logged).to.be.eql({
log: true,
mail: true
});
done();
}, 500);
});
it ('logging message to console', done => {
let logged = {
log: false
};
let fakeApp = {
mail: message => {
done('mail() should not have been called');
},
logger: () => {
done('logger() should not have been called');
},
config: {
mail: {
throttle: 0.2
}
}
};
let expectedMessage = 'This is a test';
log(fakeApp, false, expectedMessage, {
console: {
log: message => {
expect(message.indexOf(expectedMessage) !== false).to.be.equal(true);
logged.log = true;
},
error: message => {
done('console.log should not have been called');
}
}
});
setTimeout(() => {
expect(logged).to.be.eql({
log: true
});
done();
}, 500);
});
it ('logging same error only once', done => {
let logged = {
log: false,
mail: false
};
let fakeApp = {
mail: message => {
if (logged.mail) {
done('mail() was called twice');
}
expect(message.indexOf(expectedMessage) !== false).to.be.equal(true);
logged.mail = true;
},
logger: () => {
done('logger() should not have been called');
},
config: {
mail: {
throttle: 0.2
}
}
};
let expectedMessage = 'This is a test',
fakeConsole = {
error: message => {
expect(message.indexOf(expectedMessage) !== false).to.be.equal(true);
logged.log = true;
},
log: message => {
done('console.log should not have been called');
}
};
log(fakeApp, true, expectedMessage, {
console: fakeConsole,
key: 'test'
});
log(fakeApp, true, expectedMessage, {
console: fakeConsole,
key: 'test'
});
setTimeout(() => {
expect(logged).to.be.eql({
log: true,
mail: true
});
done();
}, 500);
});
});
})();

View File

@ -5,7 +5,7 @@
expect = chai.expect,
should = chai.should();
describe('Testing splitting query string', () => {
describe('Splitting query string', () => {
it('3 part requests', () => {
const exp = /^\/([a-z0-9-]+)\/([a-z0-9-]+)\.(js|json|svg)$/;

View File

@ -1,112 +0,0 @@
"use strict";
(() => {
const chai = require('chai'),
expect = chai.expect,
should = chai.should();
const Collection = require('@iconify/json-tools').Collection,
parseQuery = require('../src/query');
let collection1 = new Collection('test');
collection1.loadJSON({
prefix: 'test',
icons: {
icon1: {
body: '<icon1 fill="currentColor" />',
width: 30
},
icon2: {
body: '<icon2 />'
}
},
aliases: {
alias1: {
parent: 'icon2',
hFlip: true
}
},
width: 24,
height: 24
});
let collection2 = new Collection('test2');
collection2.loadJSON({
icons: {
'test2-icon1': {
body: '<icon1 fill="currentColor" />',
width: 30
},
'test2-icon2': {
body: '<icon2 />'
},
'test2-icon3': {
body: '<defs><foo id="bar" /></defs><bar use="url(#bar)" fill="currentColor" stroke="currentColor" />'
}
},
aliases: {
'test2-alias1': {
parent: 'test2-icon2',
hFlip: true
}
},
width: 24,
height: 24
});
describe('Testing requests', () => {
it('icons list', () => {
// Simple query with prefix
expect(parseQuery(collection1, 'icons', 'js', {
icons: 'alias1'
})).to.be.eql({
type: 'application/javascript; charset=utf-8',
body: 'SimpleSVG._loaderCallback({"prefix":"test","icons":{"icon2":{"body":"<icon2 />","width":24,"height":24}},"aliases":{"alias1":{"parent":"icon2","hFlip":true}}})'
});
// Query collection without prefix, json
expect(parseQuery(collection2, 'icons', 'json', {
icons: 'alias1'
})).to.be.eql({
type: 'application/json; charset=utf-8',
body: '{"prefix":"test2","icons":{"icon2":{"body":"<icon2 />","width":24,"height":24}},"aliases":{"alias1":{"parent":"icon2","hFlip":true}}}'
});
// Custom callback
expect(parseQuery(collection1, 'icons', 'js', {
icons: 'icon1,icon2',
callback: 'console.log'
})).to.be.eql({
type: 'application/javascript; charset=utf-8',
body: 'console.log({"prefix":"test","icons":{"icon1":{"body":"<icon1 fill=\\"currentColor\\" />","width":30,"height":24},"icon2":{"body":"<icon2 />","width":24,"height":24}}})'
});
});
it('svg', () => {
// Simple icon
expect(parseQuery(collection1, 'icon1', 'svg', {
})).to.be.eql({
filename: 'icon1.svg',
type: 'image/svg+xml; charset=utf-8',
body: '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1.25em" height="1em" style="-ms-transform: rotate(360deg); -webkit-transform: rotate(360deg); transform: rotate(360deg);" preserveAspectRatio="xMidYMid meet" viewBox="0 0 30 24"><icon1 fill="currentColor" /></svg>'
});
// Icon with custom attributes
expect(parseQuery(collection2, 'alias1', 'svg', {
color: 'red'
})).to.be.eql({
filename: 'alias1.svg',
type: 'image/svg+xml; charset=utf-8',
body: '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1em" height="1em" style="-ms-transform: rotate(360deg); -webkit-transform: rotate(360deg); transform: rotate(360deg);" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g transform="translate(24 0) scale(-1 1)"><icon2 /></g></svg>'
});
// Icon with id replacement
let result = parseQuery(collection2, 'icon3', 'svg', {
color: 'red',
rotate: '90deg'
}).body.replace(/IconifyId-[0-9a-f]+-[0-9a-f]+-[0-9]+/g, 'some-id');
expect(result).to.be.equal('<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1em" height="1em" style="-ms-transform: rotate(360deg); -webkit-transform: rotate(360deg); transform: rotate(360deg);" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g transform="rotate(90 12 12)"><defs><foo id="some-id" /></defs><bar use="url(#some-id)" fill="red" stroke="red" /></g></svg>');
});
});
})();

136
tests/request_icons_test.js Normal file
View File

@ -0,0 +1,136 @@
"use strict";
(() => {
const chai = require('chai'),
expect = chai.expect,
should = chai.should();
const Collection = require('@iconify/json-tools').Collection,
request = require('../src/request-icons');
let collection1 = new Collection('test'),
collection2 = new Collection('test2');
const parseQuery = (prefix, query, ext, params) => {
let result = null;
request({
// fake app
response: (req, res, data) => {
result = data;
},
collections: {
test1: collection1,
test2: collection2
}
}, {
// fake request
query: params
}, {}, prefix, query, ext);
return result;
};
describe('Requests for icons and collections', () => {
before(() => {
collection1.loadJSON({
prefix: 'test',
icons: {
icon1: {
body: '<icon1 fill="currentColor" />',
width: 30
},
icon2: {
body: '<icon2 />'
}
},
aliases: {
alias1: {
parent: 'icon2',
hFlip: true
}
},
width: 24,
height: 24
});
collection2.loadJSON({
icons: {
'test2-icon1': {
body: '<icon1 fill="currentColor" />',
width: 30
},
'test2-icon2': {
body: '<icon2 />'
},
'test2-icon3': {
body: '<defs><foo id="bar" /></defs><bar use="url(#bar)" fill="currentColor" stroke="currentColor" />'
}
},
aliases: {
'test2-alias1': {
parent: 'test2-icon2',
hFlip: true
}
},
width: 24,
height: 24
});
expect(collection1.items).to.not.be.equal(null);
expect(collection2.items).to.not.be.equal(null);
});
it('icons list', () => {
// Simple query with prefix
expect(parseQuery('test1', 'icons', 'js', {
icons: 'alias1'
})).to.be.eql({
type: 'application/javascript; charset=utf-8',
body: 'SimpleSVG._loaderCallback({"prefix":"test","icons":{"icon2":{"body":"<icon2 />"}},"aliases":{"alias1":{"parent":"icon2","hFlip":true}},"width":24,"height":24})'
});
// Query collection without prefix, json
expect(parseQuery('test2', 'icons', 'json', {
icons: 'alias1'
})).to.be.eql({
type: 'application/json; charset=utf-8',
body: '{"prefix":"test2","icons":{"icon2":{"body":"<icon2 />"}},"aliases":{"alias1":{"parent":"icon2","hFlip":true}},"width":24,"height":24}'
});
// Custom callback
expect(parseQuery('test1', 'icons', 'js', {
icons: 'icon1,icon2',
callback: 'console.log'
})).to.be.eql({
type: 'application/javascript; charset=utf-8',
body: 'console.log({"prefix":"test","icons":{"icon1":{"body":"<icon1 fill=\\"currentColor\\" />","width":30},"icon2":{"body":"<icon2 />"}},"width":24,"height":24})'
});
});
it('svg', () => {
// Simple icon
expect(parseQuery('test1', 'icon1', 'svg', {
})).to.be.eql({
filename: 'icon1.svg',
type: 'image/svg+xml; charset=utf-8',
body: '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1.25em" height="1em" style="-ms-transform: rotate(360deg); -webkit-transform: rotate(360deg); transform: rotate(360deg);" preserveAspectRatio="xMidYMid meet" viewBox="0 0 30 24"><icon1 fill="currentColor" /></svg>'
});
// Icon with custom attributes
expect(parseQuery('test2', 'alias1', 'svg', {
color: 'red'
})).to.be.eql({
filename: 'alias1.svg',
type: 'image/svg+xml; charset=utf-8',
body: '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1em" height="1em" style="-ms-transform: rotate(360deg); -webkit-transform: rotate(360deg); transform: rotate(360deg);" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g transform="translate(24 0) scale(-1 1)"><icon2 /></g></svg>'
});
// Icon with id replacement
let result = parseQuery('test2', 'icon3', 'svg', {
color: 'red',
rotate: '90deg'
}).body.replace(/IconifyId-[0-9a-f]+-[0-9a-f]+-[0-9]+/g, 'some-id');
expect(result).to.be.equal('<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1em" height="1em" style="-ms-transform: rotate(360deg); -webkit-transform: rotate(360deg); transform: rotate(360deg);" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g transform="rotate(90 12 12)"><defs><foo id="some-id" /></defs><bar use="url(#some-id)" fill="red" stroke="red" /></g></svg>');
});
});
})();