statiq
Version:
A static website generator
349 lines (255 loc) • 9.06 kB
JavaScript
const fs = require('fs');
const yaml = require('js-yaml');
const mkdirp = require('mkdirp-promise');
const readdirrec = require('readdirrec');
const { promisify } = require('util');
const { basename, dirname, join, relative } = require('path');
const readFile = promisify(fs.readFile);
const writeFile = promisify(fs.writeFile);
const unlink = promisify(fs.unlink);
const defaults = {
contentPath: 'content',
templatesPath: 'templates',
publishPath: 'publish',
assetsPath: 'assets',
defaultTemplate: 'index.html',
contentExtension: '.html',
publishExtension: '.html',
contextRegex: /^([\s\S]*?)\n---\n/,
hiddenRegex: /^_/,
plugins: [],
context: {},
cwd: process.cwd(),
};
function corePlugin(config) {
return {
async beforeRead(document) {
const source = await readFile(document.contentPath, 'utf8');
const matchContext = source.match(config.contextRegex);
let content = source;
if (matchContext) {
try {
Object.assign(document.context, yaml.safeLoad(matchContext[1]));
content = source.substr(matchContext[0].length);
} catch (e) {
//
}
}
return { ...document, source, content };
},
async beforeAsset(asset) {
const buffer = await readFile(asset.assetPath);
await mkdirp(dirname(asset.publishPath));
writeFile(asset.publishPath, buffer);
},
};
}
function statiq(opts = {}) {
const config = { ...defaults, ...opts };
const documents = {};
const contexts = {};
const built = {};
function paths() {
return {
content: join(config.cwd, config.contentPath),
templates: join(config.cwd, config.templatesPath),
publish: join(config.cwd, config.publishPath),
assets: join(config.cwd, config.assetsPath),
};
}
async function loadContext(dir) {
const file = join(dir, 'config');
const context = {};
await Promise.all(['.json', '.yaml', '.yml'].map(async ext => {
const ctx = await readFile(`${file}${ext}`, 'utf8').catch(() => null);
if (ctx) {
try {
Object.assign(context, yaml.safeLoad(ctx));
} catch (e) {
//
}
}
}));
return context;
}
function runPlugins(hook, initialData, ...args) {
let stop = false;
return config.plugins.concat(corePlugin(config)).reduce(async (p, plugin) => {
const currentData = await p;
if (!plugin[hook] || stop) return currentData;
// eslint-disable-next-line no-use-before-define
const newData = await plugin[hook].call(site, currentData, ...args);
if (newData === undefined) {
return currentData;
}
if (newData === null) {
stop = true;
return currentData;
}
return newData;
}, initialData);
}
const site = {
config(options) {
if (options) Object.assign(config, options);
return { ...config };
},
use(...plugins) {
config.plugins.push(...plugins);
},
async create(path, context, content) {
const filename = basename(path, config.contentExtension);
const contentDir = dirname(join(paths().content, path));
const contentPath = join(contentDir, `${filename}${config.contentExtension}`);
const source = `${context ? `${yaml.safeDump(context)}---\n` : ''}${content}`;
let document = {
contentPath,
context,
source,
content,
};
document = await runPlugins('beforeCreate', document);
if (!document) return null;
await mkdirp(contentDir);
await writeFile(contentPath, source, { flag: 'wx' });
await runPlugins('afterCreate', document);
return document;
},
async read(path) {
const contentDir = dirname(join(paths().content, path));
const publishDir = dirname(join(paths().publish, path));
const filename = basename(path, config.contentExtension);
const contentPath = join(contentDir, `${filename}${config.contentExtension}`);
const publishPath = join(publishDir, `${filename}${config.publishExtension}`);
let document = {
contentPath,
publishPath,
context: { ...config.context },
};
document = await runPlugins('beforeRead', document);
if (!document) return null;
const template = document.context.template || config.defaultTemplate;
const templatePath = join(paths().templates, template);
document = { ...document, templatePath };
await runPlugins('afterRead', document);
documents[path] = document;
return document;
},
async update(path, ctx, content) {
let document = documents[path];
let context = { ...ctx };
if (!document) throw new Error();
document = await runPlugins('beforeUpdate', document, context, content);
if (!document) return null;
if (context) context = Object.assign(document.context, context);
if (typeof content === 'string') document.content = content;
document.source = `${context ? `${yaml.safeDump(context)}---\n` : ''}${document.content}`;
await runPlugins('afterUpdate', document);
return document;
},
async delete(path) {
const contentDir = dirname(join(paths().content, path));
const publishDir = dirname(join(paths().publish, path));
const filename = basename(path, config.contentExtension);
const contentPath = join(contentDir, `${filename}${config.contentExtension}`);
const publishPath = join(publishDir, `${filename}${config.publishExtension}`);
delete documents[path];
delete built[path];
await Promise.all([
unlink(contentPath),
unlink(publishPath).catch(() => null),
]);
},
async readAll() {
await runPlugins('beforeReadAll');
const pathlist = await readdirrec(paths().content, {
filter: { ext: config.contentExtension },
});
const doclist = await Promise.all(pathlist.map(contentPath => {
const path = relative(paths().content, contentPath);
const dir = dirname(contentPath);
if (!contexts[dir]) {
contexts[dir] = {};
loadContext(dir).then(context => {
contexts[dir] = context;
});
}
return this.read(path);
}));
await runPlugins('afterReadAll');
return doclist;
},
async build(path) {
let document = documents[path];
if (!document) throw new Error();
const publishDir = dirname(document.publishPath);
const context = {
...document.context,
filename: basename(document.publishPath),
root: relative(publishDir, paths().publish) || '.',
index: {},
};
Object.entries(documents).forEach(([p, doc]) => {
const dir = dirname(p);
if (config.hiddenRegex.test(basename(doc.publishPath))) return;
if (!context.index[dir]) context.index[dir] = [];
context.index[dir].push({
...doc.context,
path: relative(publishDir, doc.publishPath),
current: doc.publishPath === document.publishPath,
});
});
document = { ...document, context };
const template = await readFile(document.templatePath, 'utf8');
document = await runPlugins('beforeBuild', document, template);
if (!document) return null;
await runPlugins('afterBuild', document);
built[path] = document;
return document;
},
async buildAll() {
await runPlugins('beforeBuildAll');
const doclist = await Promise.all(Object.keys(documents).map(this.build));
await runPlugins('afterBuildAll');
return doclist;
},
async write(path) {
let document = built[path];
if (!document) throw new Error();
document = await runPlugins('beforeWrite', document);
if (!document) return null;
await mkdirp(dirname(document.publishPath));
await writeFile(document.publishPath, document.content);
await runPlugins('afterWrite', document);
return document.publishPath;
},
async writeAll() {
await runPlugins('beforeWriteAll');
const pathlist = await Promise.all(Object.keys(built).map(this.write));
await runPlugins('afterWriteAll');
return pathlist;
},
async handleAssets() {
const assets = await readdirrec(paths().assets);
await mkdirp(paths().publish);
return Promise.all(assets.map(async assetPath => {
const publishPath = join(paths().publish, relative(paths().assets, assetPath));
let asset = { assetPath, publishPath };
asset = await runPlugins('beforeAsset', asset);
if (!asset) return;
await runPlugins('afterAsset', asset);
}));
},
list() {
return { ...documents };
},
async run() {
await this.readAll();
await this.buildAll();
await this.writeAll();
await this.handleAssets();
},
};
return site;
}
module.exports = statiq;