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