kulfon
Version:
Kulfon is a modern static site generator written in JavaScript.
720 lines (591 loc) • 19.9 kB
JavaScript
// Copyright 2020 Zaiste & contributors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
const crypto = require('crypto');
const path = require('path');
const Boxwood = require('boxwood');
const CWD = process.cwd();
const postcss = require('postcss');
const postcssPresetEnv = require('postcss-preset-env');
const tailwindcss = require('tailwindcss');
const purgecss = require('@fullhuman/postcss-purgecss')({
content: [
`${CWD}/website/pages/**/*.html`,
`${CWD}/website/parts/**/*.html`
],
defaultExtractor: content => content.match(/[\w-/:]+(?<!:)/g) || []
})
const md = require('markdown-it')();
md.use(require('markdown-it-anchor'));
md.use(require('./prism'));
md.use(require('markdown-it-highlight-lines'));
md.use(require('markdown-it-container'), 'label');
md.use(require('markdown-it-video'), {
youtube: { width: 854, height: 480, nocookie: true }
})
const fs = require('fs-extra');
const yaml = require('js-yaml');
const rollup = require('rollup').rollup;
const sha1 = require('sha1');
const matter = require('gray-matter');
const Sugar = require('sugar-date');
const svgo = require('svgo');
const { createSitemap } = require('sitemap');
const sharp = require('sharp');
const minifyHTML = require('html-minifier').minify;
const { terser } = require('rollup-plugin-terser');
const unified = require('unified');
const parse = require('orga-unified');
const mutate = require('orga-rehype');
const highlight = require('@mapbox/rehype-prism');
const html = require('rehype-stringify');
const unistMap = require('unist-util-map');
const spawn = require('child_process').spawnSync;
const fg = require('fast-glob');
const {
unique,
concat,
merge,
flatten,
isEmpty,
anchorize,
exists,
print,
println,
buildTableOfContents,
unfold
} = require('../cli/util');
const LinkExt = require('./LinkExt');
Sugar.Date.extend();
// const svgOptimizer = new svgo({});
EXTENSIONS = {
images: ['.jpg', '.png', '.jpeg', '.svg']
};
let env = { nunjucks: 'empty' }
// TODO
// env.addFilter('date', (date, format) =>
// Date.create(date).format(format || '{yyyy}-{MM}-{dd}')
// );
// TODO
// env.addFilter('logo', names => {
// NameToLogo = {
// 'Ruby': 'https://upload.wikimedia.org/wikipedia/commons/f/f1/Ruby_logo.png',
// 'TypeScript': 'https://cdn.jsdelivr.net/gh/remojansen/logo.ts@master/ts.svg',
// 'Node.js': 'https://miro.medium.com/max/400/1*tfZa4vsI6UusJYt_fzvGnQ.png',
// 'JavaScript': 'https://upload.wikimedia.org/wikipedia/commons/thumb/9/99/Unofficial_JavaScript_logo_2.svg/512px-Unofficial_JavaScript_logo_2.svg.png',
// 'Dart': 'https://pbs.twimg.com/profile_images/993555605078994945/Yr-pWI4G_400x400.jpg',
// 'Flutter': 'https://cdn.worldvectorlogo.com/logos/flutter-logo.svg'
// };
// return names
// .map(name => NameToLogo[name])
// .filter(element => element)
// .reduce((stored, current) => stored.concat('&', 'images=', encodeURIComponent(current)), '');
// });
// TODO
// env.addExtension('LinkExt', new LinkExt());
const linkify = ({ filepath: pathOnDisk }) => tree =>
unistMap(tree, node => {
const { type, uri: { protocol = '', location = '/' } = '' } = node;
if (type === 'link' && protocol === 'file') {
let l = location;
let finalPath = '';
if (location.startsWith('~')) {
l = location.split(path.join(config.directory, 'website/pages')).pop();
const { dir, name } = path.parse(l);
finalPath = path.join('/', dir, name, '/');
} else {
const splitted = pathOnDisk.split(path.sep);
splitted.pop(); // cannot chain, mutable function
splitted.pop(); // cannot chain, mutable function
const locationDir = splitted.join(path.sep);
const { dir, name } = path.parse(l);
finalPath = path.join('/', locationDir, dir, name, '/');
}
node.uri.raw = finalPath;
}
return node;
});
let cache;
let config;
let bundles = { js: '', css: '' };
let __data = {};
let __pages = {};
let __tags = {};
let __cache = {};
let __website = {};
function __public(filename, inside = '') {
return path.join(CWD, 'public', inside, filename);
}
function __current(prefix, f = '') {
return path.join(CWD, 'website', prefix, f);
}
// function profile(func, prefix, allowedExtensions) {
// return async file => {
// const result = await func(file);
// return result;
// };
// }
function filterBy(entities) {
return prefix => {
return Object.entries(entities)
.filter(
([p, meta]) => (prefix ? p.split(path.sep).includes(prefix) : true)
)
.map(([path, meta]) => meta)
.filter(_ => _.publish === undefined || _.publish === true)
.sort((a, b) => b.created_at - a.created_at);
};
}
function compile(prefix) {
const isProduction = process.env.KULFON_ENV === 'production';
const { stylesheets, javascripts, includePaths } = config;
let output;
let filename;
let compiler;
switch (prefix) {
case 'images':
compiler = async file => {
const imageExists = await exists(__public(file, 'images'));
if (imageExists) return file; // poor's man optimizaion
await fs.ensureDir(path.join(CWD, 'public', 'images', path.dirname(file)));
switch (path.extname(file)) {
case '.svg':
// const data = await fs.readFile(
// __current("images", file),
// "utf8"
// );
// const result = await svgOptimizer.optimize(data);
// fs.writeFileSync(__public(file, "images"), result.data);
fs.copy(__current('images', file), __public(file, 'images'));
break;
case '.gif':
fs.copy(__current('images', file), __public(file, 'images'));
break;
default:
await sharp(__current('images', file)).toFile(
__public(file, 'images')
);
}
};
break;
case 'javascripts':
compiler = async file => {
const dependencies = (javascripts || [])
.map(name => name.split('/')[3].split('@')[0])
.reduce((acc, name) => Object.assign(acc, { [name]: name }), {});
let options = {
input: path.join(CWD, 'website/javascripts', 'main.js'),
cache: cache,
external: Object.keys(dependencies),
plugins: terser()
};
try {
let bundle = await rollup(options);
cache = bundle;
let hash = sha1(bundle.cache.modules[0].code);
bundles.js = `main.${hash}.js`;
return bundle.write({
format: 'iife',
file: __public(bundles.js)
});
} catch (error) {
println(error.message);
}
};
break;
case 'stylesheets':
compiler = async file => {
let filePath = __current(prefix, file);
const transformers = [
tailwindcss,
postcssPresetEnv,
...isProduction ? [purgecss] : []
];
try {
const content = await fs.readFile(path.join(CWD, 'website', 'stylesheets', 'main.css'));
const { css: output } = await postcss(transformers).process(content, {
from: 'stylesheets/main.css', to: 'main.css', map: { inline: true }
});
let hash = sha1(output);
let name = path.basename(file, path.extname(file));
filename = !isProduction ? `${name}.css` : `${name}.${hash}.css`;
bundles.css = filename;
await fs.writeFile(__public(filename), output);
} catch (error) {
if (error.name === 'CssSyntaxError') {
console.error(` ${error.message}\n${error.showSourceCode()}`);
} else {
println(error.message);
}
}
};
break;
case 'pages':
compiler = async file => {
try {
const page = __pages[file];
const raw = await fs.readFile(__current(prefix, file));
__website[`${prefix}/${file}`] = raw.toString();
const { data, content } = matter(raw.toString());
const isOrg = path.extname(file) === '.org';
if (isOrg) {
const processor = unified().use(parse);
const ast = await processor.parse(content);
data.ast = ast;
}
let { filepath } = page;
let renderString = content;
let renderParams = {
language: 'en',
config,
page,
website: __data,
javascripts,
stylesheets,
bundles,
pages: filterBy(__pages)
};
const extension = path.extname(file);
if (['.md', '.org'].includes(extension)) {
const { name, dir } = path.parse(file);
const dirs = dir.split(path.sep).reverse();
let parentDir;
for (const name of dirs) {
const r = await fs.pathExists(__current('parts/layouts', path.format({ name, ext: '.html' })))
if (r) {
parentDir = name;
break;
}
}
const itselfExists = await fs.pathExists(
__current(
'parts/layouts',
path.format({ name, ext: '.njk' })
)
);
if (parentDir) {
renderString = `
<import layout from="layouts/${parentDir}.html" />
<layout {bundles} {website} {stylesheets} {page}>
<div html="{content}" class="markdown"></div>
</layout>`;
} else if (itselfExists) {
renderString = `{% extends "layouts/${name}.njk" %}`;
} else {
renderString = `
<import layout from="layouts/index.html">
<layout {bundles} {website} {stylesheets} {page}>
<div html="{content}"></div>
</layout>`;
}
if (path.extname(file) === '.md') {
const tokens = md.parse(content, {});
const toc = buildTableOfContents(0, tokens);
renderParams.toc = toc ? toc[1] : false;
renderParams.content = md.render(content);
} else if (path.extname(file) === '.org') {
const processor = await unified()
.use(parse)
.use(linkify, { filepath })
.use(mutate)
.use(highlight)
.use(html);
const { contents } = await processor.process(content);
renderParams.content = contents;
}
}
// FIXME Display compilation errors & warnings
const { template, warnings, errors } = await Boxwood.compile(renderString, { cache: false, paths: [path.join(CWD, 'website/pages'), path.join(CWD, 'website/parts')], languages: ['en', 'pl'] })
try {
output = template(renderParams, Boxwood.escape);
} catch (error) {
console.log(error.message)
}
if (isProduction)
output = minifyHTML(output, { collapseWhitespace: true });
const filename = pathname(file);
await fs.outputFile(__public('index.html', filename), output);
return page;
} catch (error) {
println(error.message);
}
};
break;
case 'root':
compiler = async file => {
fs.copy(__current('root', file), __public(file));
};
break;
default:
compiler = async file => file;
break;
}
return compiler;
}
function pathname(file) {
const { name, dir } = path.parse(file);
// detect if date in the `name`
// XXX ugly
const segments = name.split('_');
let d = Date.parse(segments[0]);
const prefix = config.blog ? config.blog.prefix : false;
if (d) {
d = new Date(d);
const year = String(d.getFullYear());
const month = ('0' + (d.getMonth() + 1)).slice(-2);
const day = String(d.getDate());
const rest = segments.slice(1).join('_');
if (prefix === 'blog') {
return path.join(dir, year, month, rest, '/');
} else if (prefix === 'none') {
return path.join(rest, '/');
} else {
return path.join(dir, rest, '/');
}
} else if (name === 'index') {
return '';
} else {
return path.join(dir, name, '/');
}
}
async function loadConfig() {
const yamlContent = await fs.readFile(
path.join(CWD, 'config.yml'),
'utf8'
);
config = yaml.safeLoad(yamlContent);
}
async function loadData() {
let dataPath = path.join(CWD, 'website');
let entries = ['data.yml']; // by default parse `data.yml`
let data = {};
try {
let stats = await fs.stat(path.join(dataPath, 'data'));
if (stats.isDirectory()) {
dataPath = path.join(dataPath, 'data');
entries = fs.readdir(dataPath);
}
} catch (error) {
// do nothing
}
let files = entries.filter(f => fs.statSync(path.join(dataPath, f)).isFile());
const content = files.reduce(
(acc, _) =>
[acc, fs.readFileSync(path.join(dataPath, _), 'utf8')].join('---\n'),
''
);
yaml.safeLoadAll(content, doc => {
data = merge(data, doc);
});
const unfolded = await unfold(data);
__data = merge(__data, unfolded);
}
const buildTagsPages = async () => {
const { stylesheets, javascripts, includePaths } = config;
const tagsPage = await fs.readFile(
__current('pages', 'tags.html'),
'utf8'
);
for (const tag in __tags) {
const { template } = await Boxwood.compile(tagsPage, { cache: false, paths: ['website/pages', 'website/parts'] })
const output = template({
tag,
posts: __tags[tag],
pages: filterBy({}),
config,
javascripts,
stylesheets
}, Boxwood.escape);
await fs.outputFile(__public('index.html', `tags/${tag}`), output);
}
};
const profile = prefix => async (fn) => {
let startTime = new Date();
print(`${'●'.red} ${prefix.padEnd(11).blue} : `);
await fn()
let endTime = new Date();
const timeAsString = `${endTime - startTime}ms`.underline;
println(`${timeAsString.padStart(18)} ${'done'.green}`);
}
const transformCSS = async () => {
await compile('stylesheets')('main.css');
}
async function transform(prefix, { force = false } = {}) {
let startTime = new Date();
const entries = await fg('**/*', {
cwd: `website/${prefix}`
});
print(`${'●'.red} ${prefix.padEnd(11).blue} : `);
// preprocessing for `pages` so to make references between them
if (prefix === 'pages') {
const __categories = {};
for (let file of entries) {
const filepath = pathname(file);
const breadcrumbs = filepath.split(path.sep).slice(0, -2);
const raw = await fs.readFile(__current(prefix, file));
__website[`${prefix}/${file}`] = raw.toString();
const { data, content } = matter(raw.toString());
const isOrg = path.extname(file) === '.org';
const lacksFrontMatter = isEmpty(data);
if (isOrg) {
const processor = unified().use(parse);
const ast = await processor.parse(content);
data.ast = ast;
}
let { title = '', created_at, abstract, categories = [], tags = [] } =
isOrg && lacksFrontMatter ? data.ast.meta : data;
let title_raw = title;
title = title.replace(/\*/g, '');
if (typeof tags === 'string') tags = [tags];
// update categories
for (let category of categories || []) {
let c = anchorize(category);
(__categories[c] = __categories[c] || []).push({
filepath,
breadcrumbs,
title,
created_at,
tags,
categories
});
}
// update tags
for (let tag of tags || []) {
let t = anchorize(tag);
(__tags[t] = __tags[t] || []).push({
filepath,
breadcrumbs,
title,
created_at,
tags,
categories
});
}
__pages[file] = {
...data,
content,
filepath,
breadcrumbs,
title,
title_raw,
abstract,
categories,
tags,
created_at
};
}
// all categories & tags from all pages to be available globally
__data.categories = Object.keys(__categories);
__data.tags = Object.keys(__tags);
}
for (let file of entries) {
try {
switch (prefix) {
case 'images':
if (!['.jpg', '.png', '.jpeg', '.svg', '.gif'].includes(path.extname(file)))
continue;
}
const raw = await fs.readFile(__current(prefix, file));
let fileKey = `${prefix}/${file}`;
__website[fileKey] = raw.toString();
// get rev
const hash = crypto.createHash('md5').update(raw).digest('hex').slice(0, 10);
if (__cache[fileKey] !== hash || force == true) {
await compile(prefix)(file);
__cache[fileKey] = hash;
}
} catch (error) {
console.log('ERROR: ', error.message);
}
}
let endTime = new Date();
const timeAsString = `${endTime - startTime}ms`.underline;
println(`${timeAsString.padStart(18)} ${'done'.green}`);
}
async function checkDirectoryStructure() {
const paths = [
'website/images',
'website/javascripts',
'website/parts',
'website/pages',
'website/stylesheets'
].map(el => path.join(CWD, el));
const result = await Promise.all(paths.map(fs.pathExists));
if (!result.every(_ => _)) {
const tree = spawn('tree', ['-d', '-I', 'node_modules'], { cwd: '.' });
throw new WrongDirectoryError(`It seems you are not in 'kulfon' compatible directory. Here's the proper structure:
. (your project root)
└── website
├── images
├── javascripts
├── parts
├── pages
└── stylesheets
but your current directory at '${CWD}' looks like this:
${tree.stdout}
`);
}
}
class WrongDirectoryError extends Error {
constructor(message) {
super(message);
}
}
async function generateSitemap() {
const sitemap = createSitemap({
hostname: config.base_url || 'https://localhost',
urls: Object.values(__pages).map(({ sitemap, filepath }) => ({
url: filepath,
priority: sitemap ? sitemap.priority : 0.5
}))
});
await fs.outputFile(__public('sitemap.xml'), sitemap.toString());
}
async function recompile(file) {
//debug(`file to recompile: ${file}`);
await loadData();
if (file.includes('pages')) {
await compile('pages')(file.split('pages/')[1]);
} else {
await transform('pages', { force: true });
}
}
async function compileAll({ dir, env }) {
process.env.KULFON_ENV = env;
try {
__cache = await fs.readJSON('public/.cache');
} catch (error) {
__cache = {};
}
try {
await fs.ensureDir('public/images');
await checkDirectoryStructure();
await loadConfig();
await loadData();
// order is important
await transform('images');
await profile('stylesheets')(transformCSS)
await transform('javascripts');
await transform('pages');
await transform('root');
await buildTagsPages();
await generateSitemap();
await fs.outputFile(path.join(CWD, 'public/.cache'), JSON.stringify(__cache));
} catch (error) {
console.error('Error: '.red + error.message);
process.exit();
}
}
module.exports = {
recompile,
compileAll
};