never-write
Version:
simple static site generator
448 lines (371 loc) • 13 kB
text/typescript
import path from 'path';
import { parseMarkdown } from '../parse-markdown';
import { getEachFilePath } from '../utils/each-file';
import { chunk, trim, assign, isString } from 'lodash';
import dayjs from 'dayjs';
import fs from 'fs-extra';
import ejs, { render } from 'ejs';
import cheerio, { html } from 'cheerio';
import { DEFAULT_EJS_TEMPLATE, NeverWriteConfig } from '../get-config/config';
import { minify } from 'html-minifier-terser';
const htmlMini = (html: string, needMini: boolean) => {
if (!needMini) {
return html;
}
return minify(html, {
minifyCSS: true,
minifyJS: true,
collapseWhitespace: true,
removeComments: true,
});
};
// -------------------------------------------------------------------------
const presetTemplates: Record<string, {
index: string;
post: string;
tagIndexes: string;
}> = {
plain: {
index: '../../../templates/theme/plain/index.html',
tagIndexes: '../../../templates/theme/plain/index.html',
post: '../../../templates/theme/plain/post.html',
},
'plain-dark': {
index: '../../../templates/theme/plain-dark/index.html',
post: '../../../templates/theme/plain-dark/post.html',
tagIndexes: '../../../templates/theme/plain-dark/index.html',
},
'cactus-dark': {
index: '../../../templates/theme/cactus-dark/index.html',
post: '../../../templates/theme/cactus-dark/post.html',
tagIndexes: '../../../templates/theme/cactus-dark/index.html',
},
};
// -------------------------------------------------------------------------
// output interface
interface Out {
relativePath: string;
sourceFileStat: fs.Stats;
filename: string;
filenameWithoutExt: string;
site: Record<string, any>;
render: Record<string, any>;
meta: Record<string, any>;
html: string;
dateFormat: {
mtime: string;
};
text: string;
summary: string;
postSavedDir: string;
postSavedFullPath: string;
staticPath: string;
namespace: string;
home: string;
}
// -------------------------------------------------------------------------
// generate posts
export const generator = async (root: string, config: NeverWriteConfig) => {
const { build, site, render: renderConfig } = config;
const { outDir } = build;
const { pageSize } = renderConfig;
const distDir = path.resolve(root, outDir);
const postsRoot = path.resolve(root, build.postsRootPath);
const home = `${build.publicPath}index.html`;
const postTemplate = (() => {
let filePath = '';
if (isString(renderConfig.template)) {
filePath = path.resolve(__dirname, `${presetTemplates[renderConfig.template].post}`)
} else if (renderConfig.template.post) {
filePath = path.join(root, renderConfig.template.post);
} else {
filePath = path.resolve(__dirname, `${presetTemplates[DEFAULT_EJS_TEMPLATE].post}`)
}
try {
return fs.readFileSync(filePath, 'utf-8');
} catch (err) {
console.log(`[INFO] ❌ missing template: ${filePath}`);
throw err;
}
})();
const indexedTemplate = (() => {
let filePath = '';
if (isString(renderConfig.template)) {
filePath = path.resolve(__dirname, `${presetTemplates[renderConfig.template].index}`)
} else if (renderConfig.template.index) {
filePath = path.join(root, renderConfig.template.index);
} else {
filePath = path.resolve(__dirname, `${presetTemplates[DEFAULT_EJS_TEMPLATE].index}`)
}
try {
return fs.readFileSync(filePath, 'utf-8');
} catch (err) {
console.log(`[INFO] ❌ missing template: ${filePath}`);
throw err;
}
})();
const tagIndexesTemplate = (() => {
let filePath = '';
if (isString(renderConfig.template)) {
filePath = path.resolve(__dirname, `${presetTemplates[renderConfig.template].tagIndexes}`)
} else if (renderConfig.template.tagIndexes) {
filePath = path.join(root, renderConfig.template.tagIndexes);
} else {
filePath = path.resolve(__dirname, `${presetTemplates[DEFAULT_EJS_TEMPLATE].tagIndexes}`)
}
try {
return fs.readFileSync(filePath, 'utf-8');
} catch (err) {
console.log(`[INFO] ❌ missing template: ${filePath}`);
throw err;
}
})();
const hook = (() => {
if (build.hook) {
try {
return require(path.join(root,build.hook));
} catch (err) {
console.log(`[INFO] ❌ missing hook file: ${build.hook}`);
throw err;
}
}
return {};
})();
// let outs: Out[] = [];
// -------------------------------------------------------------------------
// got each post meta
const sourceMarkdownFiles = getEachFilePath(postsRoot)
.filter(({ dir }) => {
return dir.endsWith('.md');
});
let outs: Out[] = sourceMarkdownFiles
// -------------------------------------------------------------------------
// make each post configuration
// if some day its slow, can use multi thread
.map(({ dir }: { dir: string }) => {
const sourceStat = fs.statSync(dir);
const str = fs.readFileSync(dir, 'utf-8');
const {
content: md,
meta,
} = parseMarkdown(str);
const relativePath = path.relative(postsRoot, dir);
const dirChunk = relativePath.split('/');
const [filename] = dirChunk.pop()?.split('.')!;
const relativeDir = dirChunk.join('/');
const postSavedDir = path.resolve(distDir, relativeDir);
const savedFilename = `${filename}.html`;
const $ = cheerio.load(md);
const textContent = trim($.text());
const postRelativePathInSite = path.join(relativeDir, savedFilename);
const out: Out = {
meta,
relativePath: postRelativePathInSite,
sourceFileStat: sourceStat,
filename: savedFilename,
filenameWithoutExt: filename,
site: config.site as any,
html: md,
dateFormat: {
mtime: dayjs(sourceStat.mtime).format(renderConfig.dateFormatter),
},
text: textContent,
summary: textContent.length > renderConfig.summaryLength
? (`${textContent.substring(0, renderConfig.summaryLength)}...`)
: textContent,
postSavedDir,
postSavedFullPath: path.resolve(postSavedDir, savedFilename),
staticPath: `${build.publicPath}${postRelativePathInSite}`,
namespace: 'post',
home,
render: renderConfig,
};
return out;
});
// -------------------------------------------------------------------------
// default is sort by modify time
outs = hook.sortBy
? outs.sort(hook.sortBy)
: outs.sort((a: Out, b: Out) => {
const mTimeA = a.sourceFileStat.mtime.getTime();
const mTimeB = b.sourceFileStat.mtime.getTime();
return mTimeB - mTimeA;
});
// -------------------------------------------------------------------------
// generate each post
// tags
interface Tag {
[propType: string]: {
posts: Out[];
staticPath: string;
};
};
const tags: Tag = {};
for (let idx = 0, len = outs.length; idx < len; idx++) {
const post = outs[idx];
const html = await htmlMini(ejs.render(postTemplate, assign(post, {
prevPost: outs[idx - 1],
nextPost: outs[idx + 1],
})), build.htmlMinify);
if (hook.eachBeforeRenderPost) {
await hook.eachBeforeRenderPost(post);
}
console.log(`[info] saving ${post.relativePath}`);
// make sure has dir
await fs.mkdirp(post.postSavedDir);
fs.writeFile(post.postSavedFullPath, html);
if (hook.eachAfterRenderPost) {
await hook.eachAfterRenderPost(post);
}
// got tag info
let tag = post.meta.tag || [];
tag.forEach((tagName: string) => {
if (!tags[tagName]) {
tags[tagName] = {
posts: [],
staticPath: `${build.publicPath}tags/${tagName}/1.html`,
};
}
tags[tagName].posts.push(post);
});
}
// -------------------------------------------------------------------------
// generate tags
{
const allTagStoreRoot = path.join(distDir, '/tags/');
for (let i of Object.entries(tags)) {
const [tagName, tagInfo] = i;
const { posts } = tagInfo;
const storeRoot = path.join(allTagStoreRoot, tagName);
const chunks = chunk(posts, pageSize);
const totalCount = posts.length;
for (let pageIndex = 0, len = chunks.length; pageIndex < len; pageIndex++) {
const chunk = chunks[pageIndex];
const page = pageIndex + 1;
const options = {
posts: chunk,
page,
pageTotal: len,
totalCount,
selfPath: `/tags/${tagName}/${page}.html`,
prevPagePath: page === 1 ? '' : `/tags/${tagName}/${page - 1}.html`,
nextPagePath: page === len ? '' : `/tags/${tagName}/${page + 1}.html`,
selfStaticPath: `${build.publicPath}tags/${tagName}/${page}.html`,
prevPageStaticPath: page === 1
? ''
: `${build.publicPath}tags/${tagName}/${page - 1}.html`,
nextPageStaticPath: page === len
? ''
: `${build.publicPath}tags/${tagName}/${page + 1}.html`,
isFirstPage: page === 1,
isEndPage: page === len,
site: config.site,
namespace: 'tag',
tag: tagName,
tags,
home,
render: renderConfig,
};
const postListHtml = await htmlMini(ejs.render(tagIndexesTemplate, options), build.htmlMinify);
const filename = `${page}.html`;
const pathPrefix = path.join('/tags/', tagName);
const hookOptions = {
...options,
html: postListHtml,
storeRoot,
filename,
pathPrefix,
};
await fs.mkdirp(storeRoot);
if (hook.eachBeforeRenderTagsIndexes) {
hook.eachBeforeRenderTagsIndexes(hookOptions);
}
console.log(`[info] saving ${path.join(pathPrefix, filename)}`);
fs.writeFileSync(path.join(storeRoot, filename), postListHtml);
if (hook.eachAfterRenderTagsIndexes) {
hook.eachAfterRenderTagsIndexes(hookOptions);
}
}
}
}
// -------------------------------------------------------------------------
// generate indexes
{
const chunks = chunk(outs, pageSize);
const totalCount = outs.length;
for (let pageIndex = 0, len = chunks.length; pageIndex < len; pageIndex++) {
const chunk = chunks[pageIndex];
const page = pageIndex + 1;
const options = {
posts: chunk,
page,
pageTotal: len,
totalCount,
selfPath: `/page/${page}.html`,
prevPagePath: page === 2
? '/index.html'
: `/page/${page - 1}.html`,
nextPagePath: `/page/${page + 1}.html`,
selfStaticPath: `${build.publicPath}page/${page}.html`,
prevPageStaticPath: page === 2
? `${build.publicPath}index.html`
: `${build.publicPath}page/${page - 1}.html`,
nextPageStaticPath: `${build.publicPath}page/${page + 1}.html`,
isFirstPage: page === 1,
isEndPage: page === len,
site: config.site,
namespace: 'indexes',
tags,
tag: '',
home,
render: renderConfig,
};
const postListHtml = await htmlMini(
ejs.render(indexedTemplate, options),
build.htmlMinify,
);
let filename = 'index.html';
let pathPrefix = '/';
if (pageIndex !== 0) {
filename = `${page}.html`;
pathPrefix = '/page/';
}
const storeRoot = path.join(distDir, pathPrefix);
const hookOptions = {
...options,
html: postListHtml,
storeRoot,
filename,
pathPrefix,
};
if (hook.eachBeforeRenderIndexes) {
hook.eachBeforeRenderIndexes(hookOptions);
}
await fs.mkdirp(storeRoot);
console.log(`[info] saving ${path.join(pathPrefix, filename)}`);
fs.writeFileSync(path.join(storeRoot, filename), postListHtml);
if (hook.eachAfterRenderIndexes) {
hook.eachAfterRenderIndexes(hookOptions);
}
}
}
// -------------------------------------------------------------------------
// copy public resource to dist root
if (build.publicResourcePath) {
try {
const publicResourcePath = path.resolve(root, build.publicResourcePath);
fs.statSync(publicResourcePath);
fs.copySync(publicResourcePath, path.resolve(distDir, build.publicResourcePath));
} catch (err) {
throw err;
}
} else {
// -------------------------------------------------------------------------
// default copy `${root}/public` to `${dist}/public`
const defaultPublicResourcePath = './public';
try {
const publicResourcePath = path.resolve(root, defaultPublicResourcePath);
fs.copySync(publicResourcePath, path.resolve(distDir, defaultPublicResourcePath));
} catch {}
}
};