أتمتة عملية نشر المقالات في مدونتك باستخدام TypeScript: دليل شامل
مقدمة: تبسيط عملية نشر المحتوى
في سعيي لبناء عادة الكتابة، أجد نفسي أكتب المزيد والمزيد من المحتوى. وبينما أستخدم منصات نشر المدونات الشهيرة مثل Medium و dev.to و Hashnode، إلا أنني أحرص أيضاً على نشر المحتوى الخاص بي على مدونتي الشخصية. بما أنني أردت بناء موقع ويب بسيط، فإن مدونتي تعتمد بشكل أساسي على HTML و CSS مع القليل جداً من JavaScript.
لكن التحدي يكمن في الحاجة إلى تحسين عملية النشر. فكيف تعمل هذه العملية حالياً؟ أقوم بإدارة خارطة طريق المدونة على Notion. وتبدو لوحة العمل كالتالي:

إنها لوحة عمل بسيطة من نوع kanban. أحب هذه اللوحة لأنها تمكنني من تحويل جميع أفكاري إلى تمثيل مادي (أو رقمي؟). أستخدمها أيضاً لبناء المسودات، وصقلها، وتحسينها باستمرار، ثم نشرها على المدونة.
لذلك، أكتب مقالات مدونتي باستخدام Notion. بعد الانتهاء، أقوم بنسخ المحتوى من Notion ولصقه في أداة عبر الإنترنت لتحويل Markdown إلى HTML. وبعد ذلك، يمكنني استخدام هذا الـ HTML لإنشاء المنشور الفعلي. لكن هذا يمثل فقط جسم المقال، أي المحتوى الرئيسي للصفحة. دائماً ما أحتاج إلى إنشاء صفحة HTML كاملة مع قسم الرأس (<head>)، والجسم (<body>)، والتذييل (<footer>).
هذه العملية مملة وتستغرق وقتاً طويلاً. لكن الخبر السار هو أنها قابلة للأتمتة! وهذا المقال يدور حول هذه الأتمتة. أرغب في أن أشارككم الكواليس وراء الأداة الجديدة التي أنشأتها، وما تعلمته خلال هذه العملية.
الميزات الأساسية للأداة: بناء قالب مرن
كانت فكرتي الرئيسية تتمحور حول الحصول على مقال HTML كامل جاهز للنشر. كما ذكرت سابقاً، فإن قسمي <head> و <footer> لا يتغيران كثيراً. لذا، يمكنني استخدامهما كـ "قالب". مع هذا القالب، يمكنني تحديد البيانات التي تتغير لكل مقال أكتبه وأنشره. هذه البيانات هي متغيرات في القالب يتم تمثيلها بالشكل التالي: {{ variableName }}. على سبيل المثال:
< h1 > {{ title }} </ h1 >
الآن يمكنني استخدام القالب واستبدال المتغيرات ببيانات حقيقية – معلومات محددة لكل مقال.
الجزء الثاني هو جسم المقال، المنشور الفعلي. في القالب، يتم تمثيله بالمتغير {{ article }}. سيتم استبدال هذا المتغير بـ HTML الذي تم إنشاؤه من محتوى Notion Markdown. عندما ننسخ ونلصق الملاحظات من Notion، نحصل على نمط يشبه Markdown. سيقوم هذا المشروع بتحويل هذا الـ Markdown إلى HTML واستخدامه كمتغير article في القالب.
لإنشاء القالب المثالي، قمت بإلقاء نظرة على جميع المتغيرات التي احتجت إلى إنشائها:
titledescriptiondatetagsimageAltimageCoverphotographerUrlphotographerNamearticlekeywords
باستخدام هذه المتغيرات، قمت بإنشاء القالب. لتمرير بعض هذه المعلومات لبناء الـ HTML، أنشأت ملف JSON كإعدادات للمقال: article.config.json. هناك، لدي شيء من هذا القبيل:
{ "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" : "<https://www.instagram.com/kazuh.illust>" , "photographerName" : "kazuh.yasiro" , "articleFile" : "article.md" , "keywords" : "javascript,react" }
قراءة القالب وملف الإعدادات
كانت الخطوة الأولى هي أن يعرف المشروع كيفية فتح وقراءة القالب وإعدادات المقال. أستخدم هذه البيانات لملء القالب. أولاً، القالب:
const templateContent: string = await getTemplateContent();
لذلك، نحتاج أساساً إلى تنفيذ دالة getTemplateContent.
import fs, { promises } from 'fs' ;
import { resolve } from 'path' ;
const { readFile } = promises;
const getTemplateContent = async (): Promise < string > => {
const contentTemplatePath = resolve(__dirname, '../examples/template.html' );
return await readFile(contentTemplatePath, 'utf8' );
};
الدالة resolve مع __dirname ستحصل على المسار المطلق للدليل من الملف المصدر الذي يتم تشغيله. ثم تنتقل إلى ملف examples/template.html. ستقوم الدالة readFile بقراءة المحتوى من مسار القالب بشكل غير متزامن وإرجاعه. الآن لدينا محتوى القالب. ونحتاج إلى القيام بنفس الشيء لإعدادات المقال.
const getArticleConfig = async (): Promise <ArticleConfig> => {
const articleConfigPath = resolve(__dirname, '../examples/article.config.json' );
const articleConfigContent = await readFile(articleConfigPath, 'utf8' );
return JSON .parse(articleConfigContent);
};
يحدث هنا شيئان مختلفان:
- بما أن ملف
article.config.jsonله تنسيقJSON، نحتاج إلى تحويل سلسلةJSONهذه إلى كائنJavaScriptبعد قراءة الملف. - سيكون إرجاع محتوى إعدادات المقال من نوع
ArticleConfigكما حددته في نوع إرجاع الدالة. دعونا نبنيه.
type ArticleConfig = {
title: string ;
description: string ;
date: string ;
tags: string [];
imageCover: string ;
imageAlt: string ;
photographerUrl: string ;
photographerName: string ;
articleFile: string ;
keywords: string ;
};
عندما نحصل على هذا المحتوى، نستخدم أيضاً هذا النوع الجديد.
const articleConfig: ArticleConfig = await getArticleConfig();
ملء القالب بالبيانات: قوة التعبيرات النمطية
الآن يمكننا استخدام طريقة replace لملء بيانات الإعدادات في محتوى القالب. لتوضيح الفكرة، ستبدو كالتالي:
templateContent.replace( 'title' , articleConfig.title)
لكن بعض المتغيرات تظهر أكثر من مرة في القالب. هنا يأتي دور التعبيرات النمطية (Regex) للإنقاذ. باستخدام هذا النمط:
new RegExp ( '\{\{(?:\\s+)?(title)(?:\\s+)?\}\}' , 'g' );
أحصل على جميع السلاسل التي تتطابق مع {{ title }}. لذا، يمكنني بناء دالة تستقبل معاملاً للبحث واستخدامه في مكان العنوان.
const getPattern = (find: string ): RegExp => new RegExp ( '\{\{(?:\\s+)?(' + find + ')(?:\\s+)?\}\}' , 'g' );
الآن يمكننا استبدال جميع التطابقات. مثال لمتغير العنوان:
templateContent.replace(getPattern( 'title' ), articleConfig.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)
});
الآن أقوم باستبدال الكل! نستخدمها هكذا:
const article: string = buildArticle(templateContent).with(articleConfig);
لكننا نفتقد جزأين هنا:
tagsarticle
معالجة الوسوم (Tags) ومحتوى المقال
في ملف إعدادات JSON، تمثل tags قائمة. على سبيل المثال:
['javascript', 'react'];
سيكون الـ HTML النهائي كالتالي:
< a class = "tag-link" href = "../../../tags/javascript.html" > javascript </ a >
< a class = "tag-link" href = "../../../tags/react.html" > react </ a >
لذلك، أنشأت قالباً آخر: tag_template.html مع المتغير {{ tag }}. نحتاج فقط إلى تطبيق دالة map على قائمة tags وإنشاء قالب HTML لكل وسم.
const getArticleTags = async ({ tags }: { tags: string [] }): Promise < string > => {
const tagTemplatePath = resolve(__dirname, '../examples/tag_template.html' );
const tagContent = await readFile(tagTemplatePath, 'utf8' );
return tags.map(buildTag(tagContent)).join( '' );
};
هنا نقوم بالآتي:
- الحصول على مسار قالب الوسم.
- الحصول على محتوى قالب الوسم.
- تطبيق دالة
mapعلىtagsوبناء وسمHTMLالنهائي بناءً على قالب الوسم.
الدالة buildTag هي دالة تُرجع دالة أخرى.
const buildTag = ( tagContent: string ) => (tag: string ): string => tagContent.replace(getPattern( 'tag' ), tag);
تستقبل tagContent – وهو محتوى قالب الوسم – وتُرجع دالة تستقبل وسماً وتبني وسم HTML النهائي. والآن نستدعيها للحصول على وسوم المقال.
const articleTags: string = await getArticleTags(articleConfig);
أما بالنسبة للمقال الآن، فيبدو كالتالي:
const getArticleBody = async ({ articleFile }: { articleFile: string }): Promise < string > => {
const articleMarkdownPath = resolve(__dirname, `../examples/ ${articleFile} ` );
const articleMarkdown = await readFile(articleMarkdownPath, 'utf8' );
return fromMarkdownToHTML(articleMarkdown);
};
تستقبل الدالة articleFile، ونحاول الحصول على المسار، قراءة الملف، والحصول على محتوى Markdown. ثم نمرر هذا المحتوى إلى دالة fromMarkdownToHTML لتحويل Markdown إلى HTML. لهذا الجزء، أستخدم مكتبة خارجية تسمى showdown. إنها تتعامل مع كل حالة صغيرة لتحويل Markdown إلى HTML.
import showdown from 'showdown' ;
const fromMarkdownToHTML = (articleMarkdown: string ): string => {
const converter = new showdown.Converter()
return converter.makeHtml(articleMarkdown);
};
والآن لدي الوسوم ومحتوى المقال بصيغة 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" , }
لكن يمكننا افتراض أن اسم الصورة سيكون cover. التحدي كان في الامتداد. يمكن أن يكون .png أو .jpg أو .jpeg أو .gif. لذلك، قمت ببناء دالة للحصول على امتداد الصورة الصحيح. الفكرة هي البحث عن الصورة في المجلد. إذا كانت موجودة في المجلد، أرجع الامتداد. بدأت بالجزء "الموجود".
fs.existsSync( ` ${folder} / ${fileName} . ${extension} ` );
هنا أستخدم دالة existsSync للبحث عن الملف. إذا كان موجوداً في المجلد، تُرجع true. وإلا، false. أضفت هذا الكود إلى دالة:
const existsFile = ( folder: string , fileName: string ) => (extension: string ): boolean => fs.existsSync( ` ${folder} / ${fileName} . ${extension} ` );
لماذا فعلت ذلك بهذه الطريقة؟ باستخدام هذه الدالة، أحتاج إلى تمرير folder و filename و extension. المتغيران folder و filename هما دائماً نفس الشيء. الفرق هو extension. لذلك، يمكنني بناء دالة باستخدام مفهوم الـ curry. بهذه الطريقة، يمكنني بناء دوال مختلفة لنفس folder و filename. هكذا:
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
ستبدو الدالة بأكملها كالتالي:
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' ;
};
لكنني لم أحب هذه السلسلة المكتوبة بشكل ثابت لتمثيل امتداد الصورة. الـ enum رائعة حقاً!
enum ImageExtension {
JPEG = 'jpeg' ,
JPG = 'jpg' ,
PNG = 'png' ,
GIF = 'gif'
};
والدالة الآن تستخدم الـ enum الجديدة ImageExtension:
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;
};
الآن لدي كل البيانات لملء القالب. رائع! بما أن الـ HTML قد تم إنجازه، أرغب في إنشاء ملف HTML الفعلي بهذه البيانات.
إنشاء الملفات وتنظيم المسارات
أحتاج أساساً للحصول على المسار الصحيح، الـ HTML، واستخدام دالة writeFile لإنشاء هذا الملف. للحصول على المسار، احتجت إلى فهم نمط مدونتي. إنها تنظم المجلد بالعام والشهر والعنوان، ويتم تسمية الملف index.html. مثال على ذلك:
2020/04/publisher-a-tooling-to-blog-post-publishing/index.html
في البداية، فكرت في إضافة هذه البيانات إلى ملف إعدادات المقال. لذلك، في كل مرة أحتاج إلى تحديث هذه السمة من إعدادات المقال للحصول على المسار الصحيح. لكن فكرة أخرى مثيرة للاهتمام كانت استنتاج المسار من بعض البيانات التي لدينا بالفعل في ملف إعدادات المقال. لدينا date (على سبيل المثال "2020-04-21") و title (على سبيل المثال "Publisher: tooling to automate blog post publishing"). من التاريخ، يمكنني الحصول على السنة والشهر. من العنوان، يمكنني إنشاء مجلد المقال. ملف index.html ثابت دائماً. ستبدو السلسلة كالتالي:
` ${year} / ${month} / ${slugifiedTitle} `
بالنسبة للتاريخ، الأمر بسيط حقاً. يمكنني تقسيمه بواسطة - وتفكيكه:
const [year, month]: string [] = date.split( '-' );
بالنسبة لـ slugifiedTitle، قمت ببناء دالة:
const slugify = (title: string ): string => title
.trim()
.toLowerCase()
.replace( /[^\w\s]/gi , '' )
.replace( /[\s]/g , '-' );
تقوم بإزالة المسافات البيضاء من بداية ونهاية السلسلة. ثم تحول السلسلة إلى أحرف صغيرة. ثم تزيل جميع الأحرف الخاصة (تحتفظ فقط بأحرف الكلمات والمسافات البيضاء). وأخيراً، تستبدل جميع المسافات البيضاء بـ -. تبدو الدالة بأكملها كالتالي:
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} ` );
};
تحاول هذه الدالة الحصول على مجلد المقال. إنها لا تنشئ الملف الجديد. لهذا السبب لم أضف /index.html إلى نهاية السلسلة النهائية. لماذا فعلت ذلك؟ لأنه قبل كتابة الملف الجديد، نحتاج دائماً إلى إنشاء المجلد. استخدمت mkdir مع مسار هذا المجلد لإنشائه.
const newArticleFolderPath: string = buildNewArticleFolderPath(articleConfig);
await mkdir(newArticleFolderPath, { recursive: true });
والآن يمكنني استخدام المجلد لإنشاء ملف المقال الجديد فيه.
const newArticlePath: string = ` ${newArticleFolderPath} /index.html` ;
await writeFile(newArticlePath, article);
نسخ صورة الغلاف إلى المجلد الصحيح
شيء واحد نفتقده هنا: بما أنني أضفت صورة الغلاف في مجلد إعدادات المقال، فقد احتجت إلى نسخها ولصقها في المكان الصحيح. لمثال 2020/04/publisher-a-tooling-to-blog-post-publishing/index.html، ستكون صورة الغلاف في مجلد الأصول (assets):
2020/04/publisher-a-tooling-to-blog-post-publishing/assets/cover.png
للقيام بذلك، أحتاج إلى شيئين:
- إنشاء مجلد
assetsجديد باستخدامmkdir. - نسخ ملف الصورة ولصقه في المجلد الجديد باستخدام
copyFile.
لإنشاء المجلد الجديد، أحتاج فقط إلى مسار المجلد. لنسخ ولصق ملف الصورة، أحتاج إلى مسار الصورة الحالي ومسار صورة المقال. بالنسبة للمجلد، بما أن لدي newArticleFolderPath، أحتاج فقط إلى ربط هذا المسار بمجلد الأصول.
const assetsFolder: string = ` ${newArticleFolderPath} /assets` ;
بالنسبة لمسار الصورة الحالي، لدي imageCoverFileName بالامتداد الصحيح. أحتاج فقط للحصول على مسار صورة الغلاف:
const imageCoverExamplePath: string = resolve(__dirname, `../examples/ ${imageCoverFileName} ` );
للحصول على مسار الصورة المستقبلي، أحتاج إلى ربط مسار صورة الغلاف واسم ملف الصورة:
const imageCoverPath: string = ` ${assetsFolder} / ${imageCoverFileName} ` ;
مع كل هذه البيانات، يمكنني إنشاء المجلد الجديد:
await mkdir(assetsFolder, { recursive: true });
ونسخ ولصق ملف صورة الغلاف:
await copyFile(imageCoverExamplePath, imageCoverPath);
أثناء تنفيذ جزء paths هذا، رأيت أنه يمكنني تجميعها كلها في دالة 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 };
};
كما أنشأت نوع ArticlePaths:
type ArticlePaths = {
newArticlePath: string ;
imageCoverExamplePath: string ;
imageCoverPath: string ;
assetsFolder: string ;
imageCoverFileName: string ;
};
ويمكنني استخدام الدالة للحصول على جميع بيانات المسار التي احتجتها:
const { newArticlePath, imageCoverExamplePath, imageCoverPath, assetsFolder, imageCoverFileName }: ArticlePaths = buildPaths(newArticleFolderPath);
التحقق الفوري من المنشور
الجزء الأخير من الخوارزمية الآن! أردت التحقق بسرعة من المنشور الذي تم إنشاؤه. فماذا لو تمكنت من فتح المنشور الذي تم إنشاؤه في علامة تبويب المتصفح؟ سيكون ذلك مذهلاً! لذلك فعلت ذلك:
await open(newArticlePath);
هنا أستخدم مكتبة open لمحاكاة أمر الفتح في الطرفية. وهذا كل شيء!
الدروس المستفادة من المشروع
كان هذا المشروع ممتعاً للغاية! تعلمت بعض الأشياء الرائعة خلال هذه العملية. أرغب في سردها هنا:
- بينما كنت أتعلم
TypeScript، أردت التحقق بسرعة من الكود الذي كنت أكتبه. لذلك، قمت بتكوينnodemonلتجميع وتشغيل الكود عند كل حفظ للملف. من الرائع جعل عملية التطوير ديناميكية للغاية. - حاولت استخدام وعود (
promises) مكتبةfsالجديدة فيNode.js:readFileوmkdirوwriteFileوcopyFile. وهي في حالة استقرار (Stability: 2). - قمت بالكثير من الـ
curryingلبعض الدوال لجعلها قابلة لإعادة الاستخدام. - تعد الـ
EnumsوالـTypesطرقاً جيدة لجعل الحالة متسقة فيTypeScript، ولكنها أيضاً توفر تمثيلاً وتوثيقاً جيداً لجميع بيانات المشروع. عقود البيانات (Data contracts) شيء رائع حقاً. - عقلية الأدوات (
tooling mindset). هذا أحد الأشياء التي أحبها حقاً في البرمجة. بناء أدوات لأتمتة المهام المتكررة وتسهيل الحياة.
آمل أن تكون قراءة ممتعة! استمر في التعلم والبرمجة!
نُشر هذا المقال في الأصل على مدونتي. يمكنكم متابعتي على Twitter و Github.
الموارد الإضافية
- أداة النشر: الكود المصدري
- التفكير في عقود البيانات
- دروس TypeScript
- الإغلاقات (Closures)، الـ Currying، والتجريدات الرائعة
- تعلم React ببناء تطبيق
الخلاصة التقنية
يُظهر هذا المقال ببراعة كيف يمكن لـ TypeScript أن يكون أداة قوية لأتمتة مهام تطوير الويب الروتينية، خاصة في سياق نشر المدونات. من خلال دمج إدارة القوالب، تحويل Markdown، وتوليد المسارات الذكي، يقدم الحل المطروح نموذجاً فعالاً لتقليل الجهد اليدوي. يبرز استخدام fs.promises و enums و currying الممارسات البرمجية الحديثة التي تعزز من قابلية صيانة الكود وقراءته ومرونته. إن التركيز على "عقلية الأدوات" هو جوهر الابتكار في تطوير البرمجيات، حيث يتيح للمطورين بناء حلول مخصصة لمشاكلهم المتكررة، مما يوفر الوقت ويحسن الكفاءة بشكل ملحوظ.