/* eslint-disable no-console */
/* eslint-disable import/no-extraneous-dependencies */
import * as ejs from 'ejs';
import * as fs from 'fs';
import * as path from 'path';
import MarkdownIt from 'markdown-it';
import * as katex from 'katex';
import highlightJs from 'highlight.js';
import mdFootnote from 'markdown-it-footnote';
import mdTex from 'markdown-it-texmath';
import mdAnchor from 'markdown-it-anchor';
import mdTableOfContents from 'markdown-it-table-of-contents';
import mdContainer from 'markdown-it-container';
import mdInlineComment from 'markdown-it-inline-comments';
import mdLazyImage from 'markdown-it-image-lazy-loading';
import mdMermaid from 'markdown-it-mermaid';
import PagePublisher from './PagePublisher';
import ArticleMetaInfo from './classes/ArticleMetaInfo';
import Article from './classes/Article';
import ArticleModel from './models/ArticleModel';
class ArticlePublisher {
// A path of the directory containing markdown article files.
static ARTICLE_ORIGIN_PATH: string = path.join(__dirname, '../_articles');
// A path of the directory containing HTML article files.
static ARTICLE_DIST_PATH: string = path.join(__dirname, '../app/public/article');
// A path of the article template file.
static ARTICLE_TEMPLATE: Buffer = fs.readFileSync(path.join(__dirname, '../app/templates/article.ejs'));
static IGNORED_FILES: string[] = ['.DS_Store'];
static md: MarkdownIt = new MarkdownIt({
html: false,
xhtmlOut: false,
breaks: false,
langPrefix: 'language-',
linkify: true,
typographer: true,
quotes: '“”‘’',
highlight: (str, language) => {
if (language && highlightJs.getLanguage(language)) {
return `
${highlightJs.highlight(str, { language }).value}
`;
}
return `${ArticlePublisher.md.utils.escapeHtml(str)}
`;
},
}).use(mdFootnote)
.use(mdInlineComment)
.use(mdMermaid)
.use(mdTex.use(katex), {
delimiters: 'gitlab',
})
.use(mdAnchor)
.use(mdTableOfContents, {
includeLevel: [1, 2, 3],
})
.use(mdContainer, 'toggle', {
validate(params) {
return params.trim().match(/^toggle\((.*)\)$/);
},
render(tokens, idx) {
const content = tokens[idx].info.trim().match(/^toggle\((.*)\)$/);
if (tokens[idx].nesting === 1) {
return `${ArticlePublisher.md.utils.escapeHtml(content[1])}
\n`;
}
return ' \n';
},
})
.use(mdLazyImage, {
decoding: true,
image_size: true,
base_path: path.join(__dirname, '../'),
});
/**
* Extracts content excluding front matter block.
*
* # Example
*
* ```js
* const text = '---\nid: 0\ntitle: "Lorem ipsum"\n---\nSed sit amet arcu a diam tincidunt porta';
* console.log(extractContent(text)); // 'Sed sit amet arcu a diam tincidunt porta'
* ```
*
* @param text - Any text containing front matter block.
*/
private static extractContent(text: string): string {
return text.replace(/(-{3})([\s\S]+?)(\1)/, '');
}
/**
* Returns an article in article directory as object by filename.
*
* @param filename - An article filename.
*/
public static getArticleByFilename(filename: string) {
const mdContent = String(fs.readFileSync(`${this.ARTICLE_ORIGIN_PATH}/${filename}`));
const mdContentWithToc = `::: toggle(Table of Contents)\n[[toc]]\n:::\n${mdContent}`;
const htmlContent: string = this.md.render(this.extractContent(mdContentWithToc));
const metaInfo: ArticleMetaInfo = this.extractMetaInfo(String(mdContent));
return new Article({
id: metaInfo.getId(),
title: metaInfo.getTitle(),
subtitle: metaInfo.getSubtitle(),
date: metaInfo.getDate(),
tags: metaInfo.getTags(),
content: htmlContent,
});
}
/**
* Extracts an article meta information in front matter block from text.
*
* ```js
* const text = '---\nid: 0\ntitle: "Lorem ipsum"\n---\nSed sit amet arcu a diam tincidunt porta';
* const metaInfo = extractMetaInfo(text);
*
* console.log(metaInfo.getId()); // 0
* console.log(metaInfo.getTitle()); // 'Lorem ipsum'
* ```
*
* @param text - Any text containing front matter block.
*/
public static extractMetaInfo(text: string): ArticleMetaInfo {
const metaInfo: ArticleMetaInfo = new ArticleMetaInfo();
const metaInfoLines: string[] = text.match(/(-{3})([\s\S]+?)(\1)/)[2]
.match(/[^\r\n]+/g);
if (!metaInfoLines) {
return null;
}
metaInfoLines.forEach((metaInfoLine: string) => {
const kvp: string[] = metaInfoLine.match(/(.+?):(.+)/);
if (kvp) {
const key: string = kvp[1].replace(/\s/g, '');
const value: string = kvp[2].replace(/['"]/g, '').trim();
metaInfo.setProp(key, value);
}
});
return metaInfo;
}
/**
* Converts markdown article files to HTML files.
*
* @param id - A specific article ID. If not given, publishes all articles.
*/
public static publishArticles(id?: number) {
const articleFiles: string[] = fs.readdirSync(this.ARTICLE_ORIGIN_PATH)
.filter((file) => !this.IGNORED_FILES.includes(file));
const distArticles: ArticleModel[] = articleFiles.map((articleFile: string, index: number) => {
const article = ArticlePublisher.getArticleByFilename(articleFile).getArticle();
const nextArticle = articleFiles[index + 1]
&& ArticlePublisher.getArticleByFilename(articleFiles[index + 1]).getArticle();
const prevArticle = articleFiles[index - 1]
&& ArticlePublisher.getArticleByFilename(articleFiles[index - 1]).getArticle();
if (id) {
if (article.id === id) {
console.log(`* ${article.id}: ${article.title}`);
fs.writeFileSync(
`${this.ARTICLE_DIST_PATH}/${article.id}.html`,
ejs.render(String(this.ARTICLE_TEMPLATE), {
article,
nextArticle,
prevArticle,
}),
);
}
return article;
}
fs.writeFileSync(
`${this.ARTICLE_DIST_PATH}/${article.id}.html`,
ejs.render(String(this.ARTICLE_TEMPLATE), {
article,
nextArticle,
prevArticle,
}),
);
console.log(`* ${article.id}: ${article.title}`);
return article;
});
const sortedDistArticles: ArticleModel[] = distArticles.sort((a, b) => a.id - b.id);
PagePublisher.publishArticles(sortedDistArticles);
}
}
export default ArticlePublisher;