TypeScript 項(xiàng)目實(shí)踐——自動(dòng)化發(fā)布博客文章

我最近嘗試養(yǎng)成寫(xiě)作習(xí)慣,寫(xiě)的文章比較多。我常用 ?Medium, dev.to ?和 ?Hashnode ?發(fā)布博客,同時(shí)也想在我的個(gè)人站點(diǎn)發(fā)一份。
我的個(gè)人站點(diǎn)很簡(jiǎn)單,只采用了基本的 HTML、CSS,以及少量 JavaScript。但是我考慮改進(jìn)發(fā)布過(guò)程。
從何入手呢?
我在 Notion 上做了這個(gè)博客寫(xiě)作路線(xiàn)圖:

這是一種簡(jiǎn)單的看板,我喜歡用它把所有想法呈現(xiàn)出來(lái)(或者說(shuō)把想法數(shù)字化)。我還使用它來(lái)創(chuàng)建草稿,不斷修改,然后發(fā)布。
所以我用 Notion 寫(xiě)草稿,完成后,復(fù)制 Notion 的內(nèi)容并將其粘貼到在線(xiàn)工具中,將 Markdown 轉(zhuǎn)換為 HTML 格式,然后使用 HTML 來(lái)創(chuàng)建實(shí)際發(fā)布的文章。
但這只是正文,即頁(yè)面的內(nèi)容。我需要?jiǎng)?chuàng)建整個(gè) HTML,包括頭部?jī)?nèi)容,正文和頁(yè)腳。
這個(gè)過(guò)程乏味無(wú)聊,但好消息是它可以自動(dòng)化操作。這篇文章會(huì)闡述如何自動(dòng)化。我想向你展示我創(chuàng)建的這個(gè)新工具的幕后花絮,以及我從這個(gè)過(guò)程中學(xué)到的知識(shí)。
功能
我的主要想法是準(zhǔn)備發(fā)布整個(gè) HTML 文章。正如我之前提到的, ? ?和 ? ?部分的變化不大。所以我可以將它們用作“模板”。
使用此模板,我提供的數(shù)據(jù)可以隨著我撰寫(xiě)和發(fā)布的每篇文章而改變。數(shù)據(jù)是模板中包含 ?{{ variableName }} ?的變量,舉個(gè)例子:
<h1>{{?title?}}h1>
現(xiàn)在,我可以使用模板并將變量替換為實(shí)際數(shù)據(jù),即每篇文章的特定信息。
第二部分是博客正文。在模板中,用 ?{{ article }} ?表示。該變量將被從 Notion Markdown 生成的 HTML 代替。
當(dāng)我們從 Notion 復(fù)制和粘貼筆記時(shí),我們得到了帶有 Markdown 樣式的內(nèi)容。本項(xiàng)目會(huì)將 Markdown 轉(zhuǎn)換為 HTML,并將其用作模板中的 ?article ?變量。
為了創(chuàng)建理想的模板,我總結(jié)了所需創(chuàng)建的所有變量:
titledescriptiondatetagsimageAltimageCoverphotographerUrlphotographerNamearticlekeywords
使用這些變量,我創(chuàng)建了 ?template。
要通過(guò)一些信息來(lái)構(gòu)建 HTML,我創(chuàng)建了一個(gè) ?json ?的文章配置文件 ?article.config.json,文件內(nèi)容如下:
{
??"title":?"React?Hooks,?Context?API,?and?Pokemons",
??"description":?"Understanding?how?hooks?and?the?context?api?work",
??"date":?"2020-04-21",
??"tags":?[
????"javascript",
????"react"
??],
??"imageAlt":?"The?Ash?from?Pokemon",
??"photographerUrl":?"" ,
??"photographerName":?"kazuh.yasiro",
??"articleFile":?"article.md",
??"keywords":?"javascript,react"
}第一步是項(xiàng)目應(yīng)該知道如何打開(kāi)和讀取模板以及文章配置。我使用此數(shù)據(jù)填充模板。
首先是模板:
const?templateContent:?string?=?await?getTemplateContent();
因此,我們首先需要實(shí)現(xiàn) ?getTemplateContent ?功能。
import?fs,?{?promises?}?from?'fs';
import?{?resolve?}?from?'path';
const?{?readFile?}?=?promises;
const?getTemplateContent?=?async?():?Promise?=>?{
??const?contentTemplatePath?=?resolve(__dirname,?'../examples/template.html');
??return?await?readFile(contentTemplatePath,?'utf8');
};
使用 ?dirname ?參數(shù)的 ?resolve ?將獲得正在運(yùn)行的源文件的絕對(duì)路徑目錄,然后訪(fǎng)問(wèn) ?examples/template.html ?文件, ?readFile ?將從模板路徑異步讀取并返回內(nèi)容。
現(xiàn)在我們有了模板內(nèi)容,需要對(duì)文章配置執(zhí)行相同的操作。
const?getArticleConfig?=?async?():?Promise?=>?{
??const?articleConfigPath?=?resolve(__dirname,?'../examples/article.config.json');
??const?articleConfigContent?=?await?readFile(articleConfigPath,?'utf8');
??return?JSON.parse(articleConfigContent);
};
這里發(fā)生兩件事:
由于 ? article.config.json?具有 JSON 格式,因此我們需要在讀取文件后將此 JSON 字符串轉(zhuǎn)換為 JavaScript 對(duì)象。articleConfigContent?的返回將是我在函數(shù)返回類(lèi)型中定義的 ?ArticleConfig,細(xì)節(jié)如下:type?ArticleConfig?=?{
??title:?string;
??description:?string;
??date:?string;
??tags:?string[];
??imageCover:?string;
??imageAlt:?string;
??photographerUrl:?string;
??photographerName:?string;
??articleFile:?string;
??keywords:?string;
};
獲得相關(guān)內(nèi)容后,我們還將使用這一新類(lèi)型。
const?articleConfig:?ArticleConfig?=?await?getArticleConfig();
現(xiàn)在,我們可以使用 ?replace ?方法在模板內(nèi)容中填充配置數(shù)據(jù),示例如下:
templateContent.replace('title',?articleConfig.title)
但是某些變量在模板中出現(xiàn)了不止一次,正則表達(dá)式會(huì)有助于解決這一問(wèn)題:
new?RegExp('\\{\\{(?:\\\\s+)?(title)(?:\\\\s+)?\\}\\}',?'g');
得到所有匹配 ?{{ title }} ?的字符串。我可以構(gòu)建一個(gè)接收目標(biāo)參數(shù)的函數(shù),并使用它替換 ?title。
const?getPattern?=?(find:?string):?RegExp?=>
??new?RegExp('\\{\\{(?:\\\\s+)?('?+?find?+?')(?:\\\\s+)?\\}\\}',?'g');
現(xiàn)在我們可以替換所有匹配項(xiàng)。title變量的示例:
templateContent.replace(getPattern('title'),?articleConfig.title)
但是我們并不想只替換 ?title變量,而是要替換文章配置中的所有變量,全部替換!
const?buildArticle?=?(templateContent:?string)?=>?({
??with:?(articleConfig:?ArticleAttributes)?=>
????templateContent
??????.replace(getPattern('title'),?articleConfig.title)
??????.replace(getPattern('description'),?articleConfig.description)
??????.replace(getPattern('date'),?articleConfig.date)
??????.replace(getPattern('tags'),?articleConfig.articleTags)
??????.replace(getPattern('imageCover'),?articleConfig.imageCover)
??????.replace(getPattern('imageAlt'),?articleConfig.imageAlt)
??????.replace(getPattern('photographerUrl'),?articleConfig.photographerUrl)
??????.replace(getPattern('photographerName'),?articleConfig.photographerName)
??????.replace(getPattern('article'),?articleConfig.articleBody)
??????.replace(getPattern('keywords'),?articleConfig.keywords)
});
現(xiàn)在全部替換!我們這樣使用它:
const?article:?string?=?buildArticle(templateContent).with(articleConfig);
但是我們?cè)谶@里缺少兩部分:
tagsarticle
在 JSON 配置文件中,tags ?是一個(gè)列表。對(duì)于列表:
['javascript',?'react'];
最終的 HTML 將是:
class="tag-link"?href="../../../tags/javascript.html">javascript
class="tag-link"?href="../../../tags/react.html">react
因此,我使用 ?{{ tag }} ?變量創(chuàng)建了另一個(gè)模板:?tag_template.html。我們只需要遍歷 ?tags ?列表并使用模板為每一項(xiàng)創(chuàng)建 HTML Tag。
const?getArticleTags?=?async?({?tags?}:?{?tags:?string[]?}):?Promise?=>?{
??const?tagTemplatePath?=?resolve(__dirname,?'../examples/tag_template.html');
??const?tagContent?=?await?readFile(tagTemplatePath,?'utf8');
??return?tags.map(buildTag(tagContent)).join('');
};
在這里,我們:
獲取標(biāo)簽?zāi)0迓窂?/section> 獲取標(biāo)簽?zāi)0宓膬?nèi)容 遍歷 ? tags?并根據(jù)標(biāo)簽?zāi)0鍢?gòu)建最終的 HTML Tag
buildTag ?是返回另一個(gè)函數(shù)的函數(shù)。
const?buildTag?=?(tagContent:?string)?=>?(tag:?string):?string?=>
??tagContent.replace(getPattern('tag'),?tag);
它接收參數(shù) ?tagContent ?- 這是標(biāo)簽?zāi)0宓膬?nèi)容 - 并返回一個(gè)接收 tag 參數(shù)并構(gòu)建最終標(biāo)簽 HTML 的函數(shù)。現(xiàn)在我們調(diào)用它以獲得標(biāo)簽。
const?articleTags:?string?=?await?getArticleTags(articleConfig);
現(xiàn)在這篇文章看起來(lái)像這樣:
const?getArticleBody?=?async?({?articleFile?}:?{?articleFile:?string?}):?Promise?=>?{
??const?articleMarkdownPath?=?resolve(__dirname,?`../examples/${articleFile}`);
??const?articleMarkdown?=?await?readFile(articleMarkdownPath,?'utf8');
??return?fromMarkdownToHTML(articleMarkdown);
};
它收到 ?articleFile,我們嘗試獲取路徑,讀取文件并獲取 Markdown 內(nèi)容。然后將此內(nèi)容傳遞給 ?fromMarkdownToHTML ?函數(shù),以將 Markdown 轉(zhuǎn)換為 HTML。
對(duì)于這一部分,我將使用一個(gè)外部庫(kù) ?showdown。它處理所有邊角案例,以將 Markdown 轉(zhuǎn)換為 HTML。
import?showdown?from?'showdown';
const?fromMarkdownToHTML?=?(articleMarkdown:?string):?string?=>?{
??const?converter?=?new?showdown.Converter()
??return?converter.makeHtml(articleMarkdown);
};
現(xiàn)在,我有了 tag 和文章的 HTML:
const?templateContent:?string?=?await?getTemplateContent();
const?articleConfig:?ArticleConfig?=?await?getArticleConfig();
const?articleTags:?string?=?await?getArticleTags(articleConfig);
const?articleBody:?string?=?await?getArticleBody(articleConfig);
const?article:?string?=?buildArticle(templateContent).with({
??...articleConfig,
??articleTags,
??articleBody
});
我漏掉了一件事!以前,我總是需要將圖像封面路徑添加到文章配置文件中,像這樣:
{
??"imageCover":?"an-image.png",
}
但是我們可以假設(shè)圖像名稱(chēng)為 ?cover。主要問(wèn)題是擴(kuò)展名,它可以是 ?.png,.jpg,.jpeg,或 ?.gif。
因此,我建立了一個(gè)函數(shù)來(lái)獲取正確的圖像擴(kuò)展名。這個(gè)想法是在文件夾中搜索圖像。如果它存在于文件夾中,則返回?cái)U(kuò)展名。
我從 existing 部分開(kāi)始。
fs.existsSync(`${folder}/${fileName}.${extension}`);在這里,我正在使用 ?existsSync ?方法來(lái)查找文件。如果它存在于文件夾中,則返回true,否則為 false。
我將此代碼添加到一個(gè)函數(shù)中:
const?existsFile?=?(folder:?string,?fileName:?string)?=>?(extension:?string):?boolean?=>
??fs.existsSync(`${folder}/${fileName}.${extension}`);
為什么要這樣做?
使用這個(gè)功能,我需要傳遞參數(shù) ?folder ?,filename,extension。?folder ?和 ?filename ?總是相同的,區(qū)別在于 ?extension。
因此,我可以構(gòu)建柯里化函數(shù)。這樣,我可以為相同的 ?folder ?和 ?filename ?建立不同的函數(shù),像這樣:
const?hasFileWithExtension?=?existsFile(examplesFolder,?imageName);
hasFileWithExtension('jpeg');?//?true?or?false
hasFileWithExtension('jpg');?//?true?or?false
hasFileWithExtension('png');?//?true?or?false
hasFileWithExtension('gif');?//?true?or?false
完整函數(shù)如下:
const?getImageExtension?=?():?string?=>?{
??const?examplesFolder:?string?=?resolve(__dirname,?`../examples`);
??const?imageName:?string?=?'cover';
??const?hasFileWithExtension?=?existsFile(examplesFolder,?imageName);
??if?(hasFileWithExtension('jpeg'))?{
????return?'jpeg';
??}
??if?(hasFileWithExtension('jpg'))?{
????return?'jpg';
??}
??if?(hasFileWithExtension('png'))?{
????return?'png';
??}
??return?'gif';
};
但我不喜歡用硬編碼的字符串來(lái)表示圖像擴(kuò)展名。enum ?真的很酷!
enum?ImageExtension?{
??JPEG?=?'jpeg',
??JPG?=?'jpg',
??PNG?=?'png',
??GIF?=?'gif'
};
現(xiàn)在使用我們的新的枚舉類(lèi)型 ?ImageExtension ?的函數(shù):
const?getImageExtension?=?():?string?=>?{
??const?examplesFolder:?string?=?resolve(__dirname,?`../examples`);
??const?imageName:?string?=?'cover';
??const?hasFileWithExtension?=?existsFile(examplesFolder,?imageName);
??if?(hasFileWithExtension(ImageExtension.JPEG))?{
????return?ImageExtension.JPEG;
??}
??if?(hasFileWithExtension(ImageExtension.JPG))?{
????return?ImageExtension.JPG;
??}
??if?(hasFileWithExtension(ImageExtension.PNG))?{
????return?ImageExtension.PNG;
??}
??return?ImageExtension.GIF;
};
現(xiàn)在,我獲得了用以填充模板的所有數(shù)據(jù)。
HTML 完成后,我想使用此數(shù)據(jù)創(chuàng)建實(shí)際的 HTML 文件。我大致需要獲取正確的路徑,HTML,并使用該 ?writeFile ?函數(shù)創(chuàng)建此文件。
要獲取路徑,我需要確定我的博客的形式。它使用年、月、標(biāo)題組織文件夾,文件名為 ?index.html。
一個(gè)例子是:
2020/04/publisher-a-tooling-to-blog-post-publishing/index.html
最初,我考慮過(guò)將這些數(shù)據(jù)添加到文章配置文件中。因此,我需要在每次更新時(shí)文章配置中的此屬性以獲取正確的路徑。
但是另一個(gè)有趣的想法是根據(jù)文章配置文件中已有的一些數(shù)據(jù)來(lái)推斷路徑。我們有 ?date(例如 ?"2020-04-21")和 ?title(例如 ?"Publisher: tooling to automate blog post publishing")。從 ?date ?中,我可以得到年和月。從標(biāo)題中,我可以生成文章所在文件夾。?index.html ?文件始終是不變的。
最終字符串如下所示:
`${year}/${month}/${slugifiedTitle}`
對(duì)于日期,這真的很簡(jiǎn)單,我可以拆分 ?-:
const?[year,?month]:?string[]?=?date.split('-');
對(duì)于 ?slugifiedTitle,我構(gòu)建了一個(gè)函數(shù):
const?slugify?=?(title:?string):?string?=>
??title
????.trim()
????.toLowerCase()
????.replace(/[^\\w\\s]/gi,?'')
????.replace(/[\\s]/g,?'-');
它從字符串的開(kāi)頭和結(jié)尾刪除空格,然后將字符串小寫(xiě),然后刪除所有特殊字符(僅保留單詞和空格字符),最后,將所有空白替換為 ?-。
整個(gè)函數(shù)如下所示:
const?buildNewArticleFolderPath?=?({?title,?date?}:?{?title:?string,?date:?string?}):?string?=>?{
??const?[year,?month]:?string[]?=?date.split('-');
??const?slugifiedTitle:?string?=?slugify(title);
??return?resolve(__dirname,?`../../${year}/${month}/${slugifiedTitle}`);
};
這一函數(shù)嘗試獲取文章文件夾,它不會(huì)生成新文件,這就是為什么我沒(méi)有在最終字符串的末尾添加 ?/index.html ?的原因。
為什么這樣做呢?因?yàn)樵趯?xiě)入新文件之前,我們始終需要?jiǎng)?chuàng)建文件夾。我使用 ?mkdir ?此文件夾路徑來(lái)創(chuàng)建它。
const?newArticleFolderPath:?string?=?buildNewArticleFolderPath(articleConfig);
await?mkdir(newArticleFolderPath,?{?recursive:?true?});
現(xiàn)在,我可以使用新建的文件夾,在其中創(chuàng)建新的文章文件。
const?newArticlePath:?string?=?`${newArticleFolderPath}/index.html`;
await?writeFile(newArticlePath,?article);
我們漏了一件事:當(dāng)我將圖像封面添加到文章配置文件夾中時(shí),我需要將其復(fù)制粘貼到正確的位置。
對(duì)于 ?2020/04/publisher-a-tooling-to-blog-post-publishing/index.html ?示例, 圖像封面位于 assets 文件夾中:
2020/04/publisher-a-tooling-to-blog-post-publishing/assets/cover.png
為此,我需要做兩件事:
使用 ? mkdir?創(chuàng)建一個(gè)新的 ?assets?文件夾使用 ? copyFile?復(fù)制圖像文件并將其粘貼到新文件夾中
要?jiǎng)?chuàng)建新文件夾,我只需要文件夾路徑。要復(fù)制和粘貼圖像文件,我需要當(dāng)前圖像路徑和文章圖像路徑。
對(duì)于文件夾,因?yàn)槲椰F(xiàn)有 ?newArticleFolderPath,我只需要將此路徑連接到 asset 文件夾。
const?assetsFolder:?string?=?`${newArticleFolderPath}/assets`;
對(duì)于當(dāng)前的圖像路徑,已有帶正確擴(kuò)展名的 ?imageCoverFileName,我只需要獲取圖像封面路徑:
const?imageCoverExamplePath:?string?=?resolve(__dirname,?`../examples/${imageCoverFileName}`);
為了獲得將來(lái)的圖像路徑,我需要將圖像封面路徑和圖像文件名連接起來(lái):
const?imageCoverPath:?string?=?`${assetsFolder}/${imageCoverFileName}`;
使用所有這些數(shù)據(jù),我可以創(chuàng)建新文件夾:
await?mkdir(assetsFolder,?{?recursive:?true?});
并復(fù)制并粘貼圖像封面文件:
await?copyFile(imageCoverExamplePath,?imageCoverPath);
在實(shí)現(xiàn)這一 paths ?部分時(shí),我看到可以將它們?nèi)拷M合成一個(gè)函數(shù) ?buildPaths。
const?buildPaths?=?(newArticleFolderPath:?string):?ArticlePaths?=>?{
??const?imageExtension:?string?=?getImageExtension();
??const?imageCoverFileName:?string?=?`cover.${imageExtension}`;
??const?newArticlePath:?string?=?`${newArticleFolderPath}/index.html`;
??const?imageCoverExamplePath:?string?=?resolve(__dirname,?`../examples/${imageCoverFileName}`);
??const?assetsFolder:?string?=?`${newArticleFolderPath}/assets`;
??const?imageCoverPath:?string?=?`${assetsFolder}/${imageCoverFileName}`;
??return?{
????newArticlePath,
????imageCoverExamplePath,
????imageCoverPath,
????assetsFolder,
????imageCoverFileName
??};
};
我還創(chuàng)建了 ?ArticlePaths ?類(lèi)型:
type?ArticlePaths?=?{
??newArticlePath:?string;
??imageCoverExamplePath:?string;
??imageCoverPath:?string;
??assetsFolder:?string;
??imageCoverFileName:?string;
};
而且我可以使用該函數(shù)來(lái)獲取所需的所有路徑數(shù)據(jù):
const?{
??newArticlePath,
??imageCoverExamplePath,
??imageCoverPath,
??assetsFolder,
??imageCoverFileName
}:?ArticlePaths?=?buildPaths(newArticleFolderPath);
現(xiàn)在是算法的最后一部分!我想快速驗(yàn)證創(chuàng)建的帖子。那么,如果可以在瀏覽器選項(xiàng)卡中打開(kāi)創(chuàng)建的帖子怎么樣?
所以我做到了:
await?open(newArticlePath);
在這里,我使用 ?open ?庫(kù)來(lái)模擬終端打開(kāi)命令。
就是這樣!
結(jié)語(yǔ)
這個(gè)項(xiàng)目很有趣!通過(guò)這個(gè)過(guò)程,我學(xué)到了一些很酷的東西:
在學(xué)習(xí) TypeScript 時(shí),我想快速驗(yàn)證我正在編寫(xiě)的代碼。因此,我配置 ? nodemon為在每次保存文件時(shí)編譯并運(yùn)行代碼,使開(kāi)發(fā)過(guò)程如此動(dòng)態(tài)是很酷的。嘗試用新 nodejs 的 ? fs?的 ?promises?API:readFile,mkdir,writeFile,和 ?copyFile,它目前在規(guī)范 ?Stability: 2。對(duì)某些函數(shù)進(jìn)行柯里化,使其可重復(fù)使用。 枚舉和類(lèi)型是使?fàn)顟B(tài)在 TypeScript 中保持一致的好方法,也能很好地展示和記錄項(xiàng)目數(shù)據(jù)。數(shù)據(jù)契約確實(shí)很棒。 工具化思維,這是我喜歡編程的方面,構(gòu)建工具有助于自動(dòng)執(zhí)行重復(fù)性任務(wù)并簡(jiǎn)化工作。
希望你喜歡這篇文章。
原文鏈接:https://www.freecodecamp.org/news/automating-my-blog-posts-publishing-process-with-typescript/
作者:TK
譯者:長(zhǎng)河漸落曉星沉
掃碼關(guān)注公眾號(hào),訂閱更多精彩內(nèi)容。
給個(gè)[在看],是對(duì)達(dá)達(dá)最大的支持!

