import chokidar from 'chokidar';
import express from 'express';
import fs from 'fs-extra';
import glob from 'glob-promise';
import path from 'path';
import debounce from 'lodash.debounce';
import WebSocket from 'ws';
function injectHotReloadScript(v, port) {
return typeof v === 'string'
? v.replace(
'</body>',
`
<script>new WebSocket("ws://localhost:80").addEventListener("message", event => {
if (event.data === "reload") window.location.reload();
})</script>
`
)
: v;
}
/**
* @typedef {object} FileObject
* @property {string} filepath - the location of the file on disk
* @property {string} content - the file content
*/
/**
*
* @typedef {object} Server
* @property {string} [port=3000] - the port to serve on
* @property {string} [staticPath] - An optional static path
* @property {Object.<string, string>} routes - A dictionary of key value pairs, where each key is the name of a route path, and the value is the string content that should be served for that route
*
*/
/**
* @typedef OutputMapFn
*
* @returns {FileObject[]}
*/
/**
*
* @typedef {Object} Config - Mosaic configuration
* @property {Object.<string,string>} input - a dictionary of key value pairs where key is the name of a set of files and value is a glob pattern describing the location of those files on disk
* @property {function[]} transform - an array of functions to be called in series in order to transform the input
* @property {OutputMapFn} output - a function that takes the output of the transform pipeline and then returns an FileObject array representing the files to be saved to disk
* @property {Server} [serve] - optionally serve files using an express server
*
*/
/**
*
* @param {Config} config
*
* @returns {Promise<void>}
*/
const mosaic = (config) => {
let app, routes, socket;
const hotReload = config.serve && config.watch;
if (hotReload) {
const wss = new WebSocket.Server({ port: 80 });
wss.on('connection', (s) => {
socket = s;
});
}
if (config.serve) {
let { port = 3000, staticPath = './' } = config.serve;
app = new express();
app.use(express.static(staticPath));
app.use(function (req, res, next) {
let k = req.originalUrl.split('?')[0];
if (k in routes) {
let payload = routes[k];
if (hotReload) {
payload = injectHotReloadScript(payload);
}
res.send(payload);
} else {
next();
}
});
app.listen(port, () =>
console.log(
`Express server listening on port ${port}`
)
);
}
const cache = {
/*
[mdContent]: [{ filepath, content }, { filepath, content }],
[template]: [{ filepath, content }]
*/
};
const getCacheValues = () =>
Object.keys(cache).reduce((a, k) => {
let isGlob = config.input[k].includes('*');
let values = Object.values(cache[k]);
a[k] = isGlob ? values : values[0];
return a;
}, {});
const update = () => {
let values = getCacheValues();
let { transform = [] } = config;
let transformResult = transform.reduce(
(output, fn) => fn(output),
values
);
if (config.serve) {
let { mapRoutes = () => ({}) } = config.serve;
routes = mapRoutes(transformResult);
if (hotReload && socket) socket.send('reload');
}
if (config.output) {
let { map = (v = v) } = config.output;
let output = map(transformResult);
return Promise.all(
output.map(({ filepath, content }) => {
return fs
.ensureDir(path.dirname(filepath))
.then(() =>
fs.promises.writeFile(filepath, content)
);
})
);
} else {
return Promise.resolve();
}
};
const updateCache = (key, filepath) =>
fs.promises
.readFile(filepath, 'utf8')
.then((content) => {
cache[key][filepath] = { content, filepath };
});
const populateCache = () =>
Promise.all(
Object.entries(config.input).map(
([key, globPath]) => {
cache[key] = {};
return glob(globPath).then((paths) =>
Promise.all(
paths.map((f) => updateCache(key, f))
)
);
}
)
);
const scheduleUpdate = debounce(update);
return populateCache()
.then(update)
.then(() => {
if (config.watch) {
Object.entries(config.input).forEach(
([key, glob]) => {
chokidar
.watch(glob)
.on('change', (filepath) => {
updateCache(key, filepath).then(
scheduleUpdate
);
});
}
);
//@TODO: on('unlink').on('add')
}
});
};
export default mosaic;