Skip to content

Instantly share code, notes, and snippets.

@potch
Created July 1, 2020 21:53
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save potch/17bab48af9b4ea77e3dbcab8600d4234 to your computer and use it in GitHub Desktop.
Save potch/17bab48af9b4ea77e3dbcab8600d4234 to your computer and use it in GitHub Desktop.
potch's static site generator. MIT license but I can't recommend it.
// if one file is too unwieldy you probably shouldn't have rolled your own blog yet again, POTCH
const start = Date.now();
const fs = require('fs').promises;
const path = require('path');
const Handlebars = require("handlebars");
const markdown = require("markdown-it");
const frontmatter = require("front-matter");
const prism = require('markdown-it-prism');
const mdfm = require('markdown-it-front-matter');
const mdContainer = require('markdown-it-container');
const prettier = require('prettier');
// async means we can do more stuff while waiting for IO!
const CHUNK_SIZE = 5;
const TEMPLATE_PATH = 'template';
const OUTPUT_PATH = 'build';
const INPUT_PATH = 'pages';
const PROD_SERVER = 'https://potch.me';
const checkpoint = label => console.log(`[${Date.now() - start}ms] ${label}`);
const task = async (name, obj, args={}) => [name, await obj, args];
const md = new markdown({ html: true });
// we need to recognize the frontmatter but md can ignore it
md.use(mdfm, function noop() {});
md.use(prism);
md.use(mdContainer, 'wide');
md.use(mdContainer, 'full');
md.use(mdContainer, 'fullbg');
// simple 'x days ago' thingus
Handlebars.registerHelper({
ago: function ago (d) {
d = Date.parse(d);
if (!d) return '';
d = (Date.now() - d) / 1000 | 0;
if (d < 60) return 'just now';
// Minutes
if (d < 120) return 'a minute';
if (d < 3600) return (d / 60 | 0) + ' minutes';
// Hours
d = d / 3600 | 0;
if (d < 2) return 'an hour';
if (d < 24) return d + ' hours';
// Days
d = d / 24 | 0;
if (d < 2) return 'a day';
if (d < 30) return d + ' days';
// Months
if (d < 60) return 'a month';
if (d < 360 * 1.5) return (d / 30 | 0) + ' months';
// Years
if (d < 365 * 2) return 'a year';
return Math.round(d / 365) + ' years';
}
});
const loadTemplate = async path => Handlebars.compile(
(await fs.readFile(path))
.toString('utf8')
);
async function loadTemplates(templatePath) {
const templates = {};
let templateFiles = await crawl(templatePath, { exclude: ['partials'] } );
templateFiles = templateFiles.filter(f => f.endsWith('.hbs'));
for (let file of templateFiles) {
templates[path.basename(file, '.hbs')] = await loadTemplate(file);
}
return templates;
}
async function loadPartials(partialsPath) {
let partialFiles = await crawl(partialsPath, { exclude: ['partials'] } );
for (let i = 0; i < partialFiles.length; i++) {
let chunk = [];
for (let j = 0; j < CHUNK_SIZE && i + j < partialFiles.length; j++) {
let partialFile = partialFiles[i + j];
let partialName = path.basename(partialFile, path.extname(partialFile));
chunk.push(
fs.readFile(partialFile)
.then(f => Handlebars.registerPartial(partialName, f.toString('utf8')))
);
}
await Promise.all(chunk)
}
}
// get all files in a path recursively
async function crawl(crawlPath, { exclude=[] }={}) {
let files = [];
// stack based recursion works nicely with async/await
// a symlink loop will wreck me oh well
let toCrawl = [crawlPath];
while (toCrawl.length) {
let crawlPath = toCrawl.pop();
let dirFiles = await fs.readdir(crawlPath, { withFileTypes: true });
files.push(...dirFiles
.filter(f => !f.isDirectory() && !exclude.includes(f))
.map(f => path.join(crawlPath, f.name))
);
toCrawl.push(...dirFiles
.filter(f => f.isDirectory() && !exclude.includes(f))
.map(f => path.join(crawlPath, f.name))
);
}
return files;
}
// turn a file into a document with all sorts of metadata
async function indexSingleFile(basePath, filePath) {
let raw = (await fs.readFile(filePath)).toString('utf8');
let stats = await fs.stat(filePath);
// parse the frontmatter. frontmatter may be the one good use of yaml
let meta = frontmatter(raw).attributes;
// allow for lazy authoring by extracting post titles and blurbs from markdown
let tokens = md.parse(raw, {});
for (let i = 0; i < tokens.length; i++) {
let token = tokens[i];
if (!meta.title && token.type === "heading_open" && token.tag === 'h1') {
meta.title = tokens[i + 1].content;
}
if (!meta.blurb && token.type === "paragraph_open") {
meta.blurb = md.renderInline(tokens[i + 1].content);
}
}
let docPath = path.relative(basePath, filePath);
docPath = path.join(
path.dirname(docPath),
path.basename(docPath, path.extname(docPath)) + '.html'
);
// document object
let doc = {
content: md.render(raw),
path: docPath,
meta,
raw,
changed: stats.mtime,
created: stats.birthtime
};
if (meta.date) {
doc.date = meta.date;
} else {
doc.date = doc.created;
}
return doc;
}
async function render(template, doc, args={}) {
// turn .md into .html
let outPath = path.join(process.cwd(), OUTPUT_PATH, doc.path);
// turn input path into output path
let outDir = outPath.split(path.sep);
outDir.pop();
outDir = outDir.join(path.sep);
let output = template(doc);
if (!args.skipFormatting) {
try {
output = prettier.format(output, {
parser: 'html',
jsxBracketSameLine: true,
htmlWhitespaceSensitivity: 'css',
printWidth: 100
});
} catch (e) {
console.warn(e);
}
}
// write!
// todo maybe allow for template override
return fs.mkdir(outDir, { recursive: true })
.then(dir => fs.writeFile(outPath, output));
}
async function copy(srcPath, { basePath }) {
let destPath = path.join(process.cwd(), OUTPUT_PATH, path.relative(basePath, srcPath));
// turn input path into output path
let destDir = destPath.split(path.sep);
destDir.pop();
destDir = destDir.join(path.sep);
return fs.mkdir(destDir, { recursive: true })
.then(dir => fs.copyFile(srcPath, destPath));
}
// main thing-doer
async function go() {
const basePath = path.join(process.cwd(), INPUT_PATH);
const templatePath = path.join(process.cwd(), TEMPLATE_PATH);
const templates = await loadTemplates(templatePath);
await loadPartials(path.join(templatePath, 'partials'));
const tasks = [];
let docs = [];
let files = await crawl(basePath);
// chunk-ily index all the files into documents
for (let i = 0; i < files.length; i+= CHUNK_SIZE) {
let chunk = [];
for (let j = 0; j < CHUNK_SIZE && i + j < files.length; j++) {
let file = files[i + j];
if (file.endsWith('.md')) {
let indexTask = indexSingleFile(basePath, file);
// siphon off the doc object for building the index
indexTask.then(doc => docs.push(doc));
chunk.push(task('render', indexTask));
} else {
chunk.push(task('copy', file, { basePath: basePath }));
}
}
tasks.push(...(await Promise.all(chunk)));
}
let templateStaticPath = path.join(templatePath, 'static');
let templateResources = await crawl(templateStaticPath);
templateResources.forEach(async file => {
tasks.push(await task('copy', file, { basePath: templateStaticPath }));
});
checkpoint('generating index');
// generate the index
docs = docs.filter(d => d.meta ? d.meta.published : true);
docs.sort((a, b) => a.date > b.date ? -1 : 1);
let posts = docs.slice(0, 10);
tasks.push(await task('render', {
posts,
path: 'index.html',
}, { template: 'index' }));
tasks.push(await task('render', {
posts,
path: 'feed.xml',
urlBase: PROD_SERVER,
}, { template: 'rss', skipFormatting: true }));
checkpoint('generating tags');
let tags = {};
docs.forEach(doc => {
if (doc.meta && doc.meta.tags) {
doc.meta.tags.forEach(tag => {
if (!tags[tag]) {
tags[tag] = [];
}
tags[tag].push(doc);
});
}
});
for (let [tag, docs] of Object.entries(tags)) {
tasks.push(await task('render', {
posts: docs,
path: `tag/${tag}.html`
}, { template: 'index' }));
}
checkpoint('building');
// turn those tasks into action!
for (let i = 0; i < tasks.length; i+= CHUNK_SIZE) {
let chunk = [];
for (let j = 0; j < CHUNK_SIZE && i + j < tasks.length; j++) {
let task = tasks[i + j];
let [type, obj, args] = task;
if (type === 'render') {
console.log('render', obj.path);
let template = templates.default;
if (args.template) {
if (templates[args.template]) {
template = templates[args.template];
} else {
console.warn(`unknown template "${args.template}", using default`);
}
}
chunk.push(render(template, obj, args));
}
if (type === 'copy') {
console.log('copy', obj);
chunk.push(copy(obj, args))
}
}
await Promise.all(chunk);
}
}
checkpoint('loaded');
go().then(done => {
checkpoint('done');
}).catch(e => console.error(e));
MIT License
Copyright (c) 2020 Potch
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment