Created
July 1, 2020 21:53
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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)); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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