mikel-press
Version:
A tiny and fast static site generator based on mikel templating
345 lines (326 loc) • 13 kB
JavaScript
import * as fs from "node:fs";
import * as path from "node:path";
// @description get all plugins of the given type
const getPlugins = (context, name) => {
return context.plugins.filter(plugin => typeof plugin[name] === "function");
};
// @description apply the layout to the provided node
const applyLayout = page => {
return `{{>>layout:${page.attributes.layout}}}\n\n${page.content}\n\n{{/layout:${page.attributes.layout}}}\n`;
};
// @description press main function
// @param {Object} config - configuration object
// @param {String} config.source - source folder
// @param {String} config.destination - destination folder to save the files
// @param {Array} config.plugins - list of plugins to apply
const press = (config = {}) => {
const context = press.createContext(config);
press.buildContext(context, context.nodes);
if (config.watch === true) {
press.watchContext(context);
}
};
// @description create a context object
press.createContext = (config = {}) => {
const { source, destination, plugins, extensions, exclude, template, watch, ...otherConfig } = config;
const context = Object.freeze({
config: otherConfig,
source: path.resolve(source || "."),
destination: path.resolve(destination || "./www"),
extensions: extensions || [ ".html", ".mustache" ],
exclude: exclude || [ "node_modules", ".git", ".gitignore", ".github" ],
template: template,
plugins: [
press.SourcePlugin({ folder: ".", label: press.LABEL_PAGE }),
...plugins,
],
nodes: [],
});
getPlugins(context, "init").forEach(plugin => {
return plugin.init(context);
});
// load nodes into context
const nodesPaths = new Set(); // prevent adding duplicated nodes
getPlugins(context, "load").forEach(plugin => {
[plugin.load(context) || []].flat().forEach(node => {
if (nodesPaths.has(node.source)) {
throw new Error(`File ${node.source} has been already processed by another plugin`);
}
context.nodes.push(node);
nodesPaths.add(node.source);
});
});
return context;
};
// @description build the provided context
press.buildContext = (context, nodesToBuild = null) => {
const nodes = Array.isArray(nodesToBuild) ? nodesToBuild : context.nodes;
// 1. transform nodes
getPlugins(context, "transform").forEach(plugin => {
// special hook to initialize the transform plugin
if (typeof plugin.beforeTransform === "function") {
plugin.beforeTransform(context);
}
// run the transform in all nodes
nodes.forEach((node, _, allNodes) => {
return plugin.transform(context, node, allNodes);
});
});
// 2. filter nodes and get only the ones that are going to be emitted
const shouldEmitPlugins = getPlugins(context, "shouldEmit");
const filteredNodes = nodes.filter((node, _, allNodes) => {
return shouldEmitPlugins.every(plugin => {
return !!plugin.shouldEmit(context, node, allNodes);
});
});
// 3. before emit
getPlugins(context, "beforeEmit").forEach(plugin => {
return plugin.beforeEmit(context);
});
// 4. emit each node
filteredNodes.forEach(node => {
// 1. if node has been processed (aka node.content is an string), write the file
if (typeof node.content === "string") {
press.utils.write(path.join(context.destination, node.path), node.content);
}
// 2. if node has not been processed, just copy the file
else if (fs.existsSync(node.source)) {
press.utils.copy(node.source, path.join(context.destination, node.path));
}
});
};
// @description start a watch on the current context
press.watchContext = (context, options = {}) => {
const labelsToWatch = options.labels || [press.LABEL_PAGE, press.LABEL_PARTIAL, press.LABEL_DATA];
const nodesToRebuild = context.nodes.filter(node => labelsToWatch.includes(node.label));
const rebuild = () => press.buildContext(context, nodesToRebuild);
// create a watch for each registered node in the context
nodesToRebuild.forEach(node => {
press.utils.watch(node.source, rebuild);
});
};
// @description general utilities
press.utils = {
// @description read a file from disk
// @param {String} file path to the file to read
read: (file, encoding = "utf8") => {
return fs.readFileSync(file, encoding);
},
// @description write a file to disk
// @param {String} file path to the file to save
// @param {String} content content to save
write: (file, content = "") => {
const folder = path.dirname(file);
if (!fs.existsSync(folder)) {
fs.mkdirSync(folder, {recursive: true});
}
fs.writeFileSync(file, content, "utf8");
},
// @description copy a file
copy: (source, target) => {
const folder = path.dirname(target);
if (!fs.existsSync(folder)) {
fs.mkdirSync(folder, {recursive: true});
}
fs.copyFileSync(source, target);
},
// @description get all files from the given folder and the given extensions
readdir: (folder, extensions = "*", exclude = []) => {
if (!fs.existsSync(folder) || !fs.statSync(folder).isDirectory()) {
return [];
}
return fs.readdirSync(folder, "utf8")
.filter(file => (extensions === "*" || extensions.includes(path.extname(file))) && !exclude.includes(file))
.filter(file => fs.statSync(path.join(folder, file)).isFile());
},
// @description watch for file changes
// @param {String} filePath path to the file to watch
// @param {Function} listener method to listen for file changes
watch: (filePath, listener) => {
let lastModifiedTime = null;
fs.watch(filePath, "utf8", () => {
const modifiedTime = fs.statSync(filePath).mtimeMs;
if (lastModifiedTime !== modifiedTime) {
lastModifiedTime = modifiedTime;
return listener(filePath);
}
});
},
// @description frontmatter parser
// @params {String} content content to parse
// @params {Function} parser parser function to use
frontmatter: (content = "", parser = JSON.parse) => {
const matches = Array.from(content.matchAll(/^(--- *)/gm))
if (matches?.length === 2 && matches[0].index === 0) {
return {
body: content.substring(matches[1].index + matches[1][1].length).trim(),
attributes: parser(content.substring(matches[0].index + matches[0][1].length, matches[1].index).trim()),
};
}
return {body: content, attributes: {}};
},
};
// assign constants
press.LABEL_PAGE = "page";
press.LABEL_ASSET = "asset";
press.LABEL_DATA = "asset/data";
press.LABEL_PARTIAL = "asset/partial";
press.LABEL_LAYOUT = "asset/layout";
// @description source plugin
press.SourcePlugin = (options = {}) => {
const shouldEmit = options?.emit ?? true, shouldRead = options.read ?? true;
const processedNodes = new Set();
return {
load: context => {
const folder = path.join(context.source, options?.folder || ".");
const extensions = options?.extensions || context.extensions;
const exclude = options?.exclude || context.exclude;
return press.utils.readdir(folder, extensions, exclude).map(file => {
processedNodes.add(path.join(folder, file)); // register this node
return {
source: path.join(folder, file),
label: options.label || press.LABEL_PAGE,
path: path.join(options?.basePath || ".", file),
url: path.normalize("/" + path.join(options?.basePath || ".", file)),
};
});
},
transform: (context, node) => {
if (processedNodes.has(node.source) && shouldRead) {
node.content = press.utils.read(node.source);
}
},
shouldEmit: (context, node) => {
return !processedNodes.has(node.source) || shouldEmit;
},
};
};
// @description data plugin
press.DataPlugin = (options = {}) => {
return press.SourcePlugin({
folder: "./data",
emit: false,
extensions: [".json"],
label: press.LABEL_DATA,
...options,
});
};
// @description partials plugin
press.PartialsPlugin = (options = {}) => {
return press.SourcePlugin({
folder: "./partials",
emit: false,
label: press.LABEL_PARTIAL,
...options,
});
};
// @description assets plugin
press.AssetsPlugin = (options = {}) => {
return press.SourcePlugin({
folder: "./assets",
read: false,
extensions: "*",
label: press.LABEL_ASSET,
...options,
});
};
// @description layouts plugin
press.LayoutsPlugin = (options = {}) => {
return press.SourcePlugin({
folder: "./layouts",
label: press.LABEL_LAYOUT,
emit: false,
...options,
});
};
// @description frontmatter plugin
press.FrontmatterPlugin = () => {
return {
transform: (_, node) => {
if (typeof node.content === "string") {
const result = press.utils.frontmatter(node.content, JSON.parse);
node.content = result.body || "";
node.attributes = result.attributes || {};
node.title = node.attributes?.title || node.path;
if (node.attributes.permalink) {
node.path = node.attributes.permalink;
node.url = path.normalize("/" + node.path);
}
}
},
};
};
// @description plugin to generate pages content
press.ContentPagePlugin = (siteData = {}) => {
return {
beforeTransform: context => {
const getNodes = label => context.nodes.filter(n => n.label === label);
// 1. prepare site data
Object.assign(siteData, context.config, {
pages: getNodes(press.LABEL_PAGE),
data: Object.fromEntries(getNodes(press.LABEL_DATA).map(node => {
return [path.basename(node.path, ".json"), JSON.parse(node.content)];
})),
partials: getNodes(press.LABEL_PARTIAL),
layouts: getNodes(press.LABEL_LAYOUT),
assets: getNodes(press.LABEL_ASSET),
});
// 2. register partials into template
siteData.partials.forEach(partial => {
context.template.addPartial(path.basename(partial.path), {
body: partial.content || "",
attributes: partial.attributes || {},
});
});
// 3. process layouts files
siteData.layouts.forEach(layout => {
// 3.1. apply the layout to this layout node
if (layout?.attributes?.layout && layout?.content) {
layout.content = applyLayout(layout);
}
// 3.2. register layouts into the template
context.template.addPartial("layout:" + path.basename(layout.path), {
body: layout.content || "",
attributes: layout.attributes || {},
});
});
// 4. apply layouts to all pages
if (siteData.layouts.length > 0 && siteData.pages.length > 0) {
siteData.pages.forEach(page => {
if (page?.attributes?.layout && page?.content) {
page.content = applyLayout(page);
}
});
}
},
transform: (context, node) => {
if (node.label === press.LABEL_PAGE && typeof node.content === "string") {
node.content = context.template(node.content, { site: siteData, page: node });
}
},
};
};
// @description plugin to register mikel helpers and functions
press.UsePlugin = mikelPlugin => {
return {
init: context => {
context.template.use(mikelPlugin);
},
};
};
// @description copy plugin
press.CopyAssetsPlugin = (options = {}) => {
return {
load: () => {
return (options?.patterns || [])
.filter(item => item.from && fs.existsSync(path.resolve(item.from)))
.map(item => ({
source: path.resolve(item.from),
path: path.join(options?.basePath || ".", item.to || path.basename(item.from)),
label: options?.label || press.LABEL_ASSET,
}));
},
};
};
// export press generator
export default press;