hads
Version:
Fast web server allowing to browse, search and edit project documentation written in Markdown
421 lines (375 loc) • 12.5 kB
JavaScript
;
const fs = require('fs-extra');
const path = require('path');
const express = require('express');
const multer = require('multer');
const shortId = require('shortid');
const moment = require('moment');
const pkg = require('./package.json');
const pug = require('pug');
const globby = require('globby');
const Matcher = require('./lib/matcher.js');
const Renderer = require('./lib/renderer.js');
const Helpers = require('./lib/helpers.js');
const Indexer = require('./lib/indexer.js');
const yargs = require('yargs/yargs')(process.argv.slice(2))
.usage(`\n${pkg.name} ${pkg.version}\nUsage: $0 [root dir] [options]`)
.version(false)
.option('port', {
alias: 'p',
describe: 'Port number to listen on',
default: 4040
})
.option('host', {
alias: 'h',
describe: 'Host address to bind to',
default: 'localhost'
})
.option('images-dir', {
alias: 'i',
describe: 'Directory to store images',
default: 'images'
})
.option('open', {
alias: 'o',
describe: 'Open default browser on start'
})
.option('readonly', {
alias: 'r',
describe: 'Read-only mode (no add or edit feature)'
})
.option('export', {
alias: 'e',
describe: 'Export static html'
})
.help(false) // Needed to avoid extra [boolean] type
.option('help', {describe: 'Show this help'});
const args = yargs.argv;
if (args.help || args._.length > 1) {
yargs.showHelp(console.log);
process.exit();
}
const INDEXES = [
'index.md',
'README.md',
'readme.md'
];
const ICONS = {
alert: 'octicon-alert',
file: 'octicon-file',
search: 'octicon-search',
fileCode: 'octicon-file-code'
};
const STYLESHEET = 'custom.css';
const PATHS = {
documentation: args._[0] || './',
public: path.join(__dirname, 'public'),
views: path.join(__dirname, 'views'),
/* eslint-disable no-return-assign */
get code() {
delete this.code;
return this.code = path.join(this.root, '**', `*.{${Matcher.getCode().join(',')}}`);
},
get customStylesheet() {
delete this.customStylesheet;
return this.customStylesheet = path.join(this.root, STYLESHEET);
},
get export() {
delete this.export;
return this.export = path.join(process.cwd(), args.export === true ? 'public' : args.export);
},
get hads() {
delete this.hads;
return this.hads = path.join(this.export, '_hads');
},
get images() {
delete this.images;
return this.images = path.join(this.root, Helpers.sanitizePath(args.i));
},
get root() {
delete this.root;
return this.root = path.resolve(this.documentation);
}
/* eslint-enable no-return-assign */
};
const SCRIPTS = [
'/ace/ace.js',
'/mermaid/mermaid.min.js',
'/dropzone/dropzone.min.js',
'/js/client.js'
];
const STYLESHEETS = [
'/highlight/github.css',
'/octicons/octicons.css',
'/css/github.css',
'/css/style.css',
'/font-awesome/css/font-awesome.css'
].concat(fs.existsSync(PATHS.customStylesheet) ? [`/${STYLESHEET}`] : []);
const indexer = new Indexer(PATHS.root);
const renderer = new Renderer(indexer, {
isExport: args.export
});
if (args.export) {
args.export = args.export === true ? './public' : args.export;
(async () => {
await fs.emptyDir(PATHS.export);
globby(path.join(PATHS.root, '**', `*.{${Matcher.getImages().join(',')}}`))
.then(images => {
images.map(file => {
const dest = file.replace(PATHS.root, '');
return fs.copy(file, path.join(PATHS.export, dest));
});
});
const [documentation, code] = await Promise.all([
indexer.indexFiles().then(files => files.map(file => file.file)),
globby(PATHS.code).then(paths => paths.map(path => path.replace(PATHS.root, '')))
]);
documentation
.concat(code)
.forEach(async file => {
const dir = path.dirname(file);
const ext = path.extname(file);
const base = path.basename(file, ext);
const src = path.join(PATHS.documentation, file);
const content = Matcher.isMarkdown(file) ?
await renderer.renderFile(src) :
await renderer.renderCode(src);
const template = path.join(PATHS.views, 'file.pug');
const html = pug.renderFile(template, {
content,
pkg,
route: Helpers.extractRoute(file),
icon: ICONS.file,
readonly: true,
scripts: SCRIPTS,
static: true,
styles: STYLESHEETS,
title: path.basename(src)
});
const buffer = Buffer.from(html);
const array = new Uint8Array(buffer);
const dest = path.join(dir, `${base}.html`);
const isIndex = INDEXES.find(filename => filename.startsWith(base));
await fs.mkdirp(path.join(PATHS.export, dir));
fs.writeFile(path.join(PATHS.export, dest), array);
if (isIndex) {
const dest = path.join(dir, 'index.html');
fs.writeFile(path.join(PATHS.export, dest), array);
}
});
// We `await` because we'll get EEXIST if this and the copy to
// the same directory in Helpers.processPackages are unresolved.
await fs.copy(PATHS.public, PATHS.hads);
if (await fs.exists(PATHS.customStylesheet)) {
fs.copy(path.join(PATHS.root, STYLESHEET), path.join(PATHS.hads, STYLESHEET));
}
Helpers.processPackages((alias, path$) => {
const dest = path.join(PATHS.hads, alias);
fs.copy(path$, dest);
});
})();
return;
}
const app = express();
app.set('views', PATHS.views);
app.set('view engine', 'pug');
app.use(express.json());
app.use(express.urlencoded({
extended: true
}));
app.use('/_hads/', express.static(path.join(__dirname, '/public')));
Helpers.processPackages((alias, path$) => {
app.use('/_hads/' + alias + '/', express.static(path$));
});
if (fs.existsSync(PATHS.customStylesheet)) {
app.use(`/_hads/${STYLESHEET}`, express.static(PATHS.customStylesheet));
}
app.get('*', (req, res, next) => {
let route = Helpers.extractRoute(req.path);
const query = req.query || {};
let rootIndex = -1;
let mdIndex = -1;
const create = Helpers.hasQueryOption(query, 'create');
let edit = Helpers.hasQueryOption(query, 'edit') || create;
let statusCode = 200;
let lastModified = '';
let filePath, icon, search, error, title, contentPromise;
function renderPage() {
if (error) {
edit = false;
contentPromise = Promise.resolve(renderer.renderMarkdown(error));
icon = ICONS.alert;
} else if (search) {
contentPromise = renderer.renderSearch(query.search);
icon = ICONS.search;
} else if (Helpers.hasQueryOption(query, 'raw') || Matcher.isImage(filePath)) {
return res.sendFile(filePath);
} else if (Matcher.isMarkdown(filePath)) {
contentPromise = edit ? renderer.renderRaw(filePath) : renderer.renderFile(filePath);
icon = ICONS.file;
} else if (Matcher.isCode(filePath)) {
contentPromise = renderer.renderCode(filePath);
icon = ICONS.fileCode;
}
if (!title) {
title = search ? renderer.searchResults : path.basename(filePath);
}
if (contentPromise) {
return fs.stat(filePath)
.then(stat => {
if (stat.isFile()) {
lastModified = moment(stat.mtime).fromNow();
}
return contentPromise;
})
.then(content => {
res.status(statusCode);
res.render(edit ? 'edit' : 'file', {
title,
lastModified,
readonly: args.readonly,
route,
icon,
search,
content,
styles: STYLESHEETS,
scripts: SCRIPTS,
pkg
});
})
.catch(err => {
console.error(`Error while retrieving file stats: ${err.code}`);
next();
});
}
return next();
}
function tryProcessFile() {
contentPromise = null;
filePath = path.join(PATHS.root, route);
return fs.stat(filePath)
.then(stat => {
search = query.search && query.search.length > 0 ? query.search.trim() : null;
if (stat.isDirectory() && !search && !error) {
if (!create) {
// Try to find a root file
route = path.join(route, INDEXES[++rootIndex]);
return tryProcessFile();
}
route = '/';
title = 'Error';
error = `Cannot create file \`${filePath}\``;
statusCode = 400;
}
return renderPage();
})
.catch(() => {
if (create) {
const fixedRoute = Helpers.ensureMarkdownExtension(route);
if (fixedRoute !== route) {
return res.redirect(fixedRoute + '?create=1');
}
return fs.mkdirp(path.dirname(filePath))
.then(() => fs.writeFile(filePath, ''))
.then(() => indexer.updateIndexForFile(filePath))
.then(tryProcessFile)
.catch(e => {
console.error(e);
title = 'Error';
error = `Cannot create file \`${filePath}\``;
route = '/';
statusCode = 400;
return renderPage();
});
}
if (rootIndex !== -1 && rootIndex < INDEXES.length - 1) {
route = path.join(path.dirname(route), INDEXES[++rootIndex]);
return tryProcessFile();
}
if (rootIndex === -1 && path.basename(route) !== '' && (path.extname(route) === '' || mdIndex > -1) &&
mdIndex < Matcher.MARKDOWN_EXTENSIONS.length - 1) {
// Maybe it's a github-style link without extension, let's try adding one
const extension = Matcher.MARKDOWN_EXTENSIONS[++mdIndex];
route = path.join(path.dirname(route), `${path.basename(route, path.extname(route))}.${extension}`);
return tryProcessFile();
}
if (path.dirname(route) === path.sep && rootIndex === INDEXES.length - 1) {
error = '## No home page (╥﹏╥)\nDo you want to create an [index.md](/index.md?create=1) or ' +
'[readme.md](/readme.md?create=1) file perhaps?';
} else {
error = '## File not found ¯\\\\\\_(◕\\_\\_◕)_/¯\n> *There\'s a glitch in the matrix...*';
}
title = '404 Error';
route = '/';
statusCode = 404;
return renderPage();
});
}
tryProcessFile();
});
if (!args.readonly) {
app.post('*', (req, res, next) => {
const route = Helpers.extractRoute(req.path);
const filePath = path.join(PATHS.root, route);
let lastModified = '';
fs.stat(filePath)
.then(stat => {
let fileContent = req.body.content;
if (stat.isFile() && fileContent) {
lastModified = moment(stat.mtime).fromNow();
if (process.platform !== 'win32') {
// Www-form-urlencoded data always use CRLF line endings, so this is a quick fix
fileContent = fileContent.replace(/\r\n/g, '\n');
}
return fs.writeFile(filePath, fileContent);
}
return null;
})
.then(() => {
indexer.updateIndexForFile(filePath);
return renderer.renderFile(filePath);
})
.then(content => res.render('file', {
title: path.basename(filePath),
lastModified,
readonly: args.readonly,
route,
icon: 'octicon-file',
content,
styles: STYLESHEETS,
scripts: SCRIPTS,
pkg
}))
.catch(() => {
next();
});
});
app.post('/_hads/upload', [multer({
storage: multer.diskStorage({
destination: (req, file, cb) => {
cb(null, PATHS.images);
},
filename: (req, file, cb) => {
fs.mkdirp(PATHS.images).then(() => {
cb(null, shortId.generate() + path.extname(file.originalname));
});
}
}),
onFileUploadStart: file => !file.mimetype.match(/^image\//),
limits: {
fileSize: 1024 * 1024 * 10 // 10 MB
}
}).single('file'), (req, res) => {
res.json(path.sep + path.relative(PATHS.root, req.file.path));
}]);
}
indexer.indexFiles().then(() => {
app.listen(args.port, args.host, () => {
const serverUrl = `http://${args.host}:${args.port}`;
console.log(`${pkg.name} ${pkg.version} serving at ${serverUrl} (press CTRL+C to exit)`);
if (args.open) {
require('open')(serverUrl, {url: true});
}
});
});
module.exports = app;