المدونة التي تقرأها الآن بلا قاعدة بيانات وبلا نظام إدارة محتوى مستضاف. كل مقالة مجلد في مستودع Git يحتوي على index.md وصوره. وعند دفع أي إيداع (commit) يُبنى الموقع وتُنشر المقالة. إليك كيف تبني الشيء نفسه باستخدام Next.js App Router.
لماذا Markdown داخل Git
- مجاني وسريع. لا اشتراك في نظام إدارة محتوى، ولا استعلامات لقاعدة بيانات؛ كل صفحة HTML ثابت.
- مُتتبَّع الإصدارات. كل تعديل إيداع يمكنك مراجعته والتراجع عنه.
- قابل للنقل. يعيش Markdown أطول من أي منصة. والانتقال إلى إطار عمل آخر يعني تغيير أداة العرض، لا ترحيل المحتوى.
- قابل للتعديل من أي مكان. محرر GitHub على الويب، أو VS Code، أو نظام إدارة محتوى مبني على Git مثل Decap أو Sveltia لمن يفضّل النماذج.
المقابل: يحتاج المحررون غير التقنيين إلى قليل من Git، أو إلى نظام إدارة محتوى مبني على Git أمامه.
بنية المجلدات
مجلد لكل مقالة يُبقي الصور بجوار النص الذي يستخدمها، كما تظهر الروابط النسبية بشكل صحيح في معاينة GitHub:
content/blog/
my-first-post/
index.md
cover.jpg
diagram.pngتبدأ المقالة ببيانات وصفية بصيغة YAML:
---
title: My first post
date: 2026-10-08
excerpt: One or two sentences for the listing and the meta description.
cover: ./cover.jpg
tags: [Next.js, Markdown]
---
The post starts here.الاعتماديات
npm install gray-matter remark remark-gfm remark-html highlight.jsgray-matterتفصل البيانات الوصفية عن المتن.remarkمعremark-gfmتحلّلان Markdown مع امتدادات GitHub (الجداول وقوائم المهام).remark-htmlتحوّله إلى HTML.highlight.jsتلوّن كتل الشيفرة أثناء البناء، فلا يحمّل القرّاء أي أداة تلوين.
تحميل المقالات
import fs from 'node:fs'
import path from 'node:path'
import matter from 'gray-matter'
import { remark } from 'remark'
import remarkGfm from 'remark-gfm'
import remarkHtml from 'remark-html'
const DIR = path.join(process.cwd(), 'content/blog')
export async function getAllPosts() {
const slugs = fs.readdirSync(DIR).filter((name) =>
fs.existsSync(path.join(DIR, name, 'index.md')),
)
const posts = await Promise.all(slugs.map(getPost))
return posts.sort((a, b) => b.date.localeCompare(a.date))
}
export async function getPost(slug: string) {
const file = fs.readFileSync(path.join(DIR, slug, 'index.md'), 'utf8')
const { data, content } = matter(file)
const html = String(
await remark().use(remarkGfm).use(remarkHtml, { sanitize: false }).process(content),
)
return {
slug,
title: String(data.title),
date: new Date(data.date).toISOString().slice(0, 10),
excerpt: String(data.excerpt ?? ''),
html,
}
}يسمح sanitize: false بمرور HTML الخام في المقالات، ولا بأس بذلك إن كنت الكاتب الوحيد. أما إن كان بإمكان آخرين إرسال مقالات، فاستمر في التنقية.
وبما أن هذا لا يعمل إلا في مكونات الخادم وgenerateStaticParams، فلا مشكلة في fs: إنه يعمل أثناء البناء، لا في المتصفح أبدًا.
الصفحات
تعرض صفحة الفهرس المقالات، ومسار التفاصيل ثابت لكل slug:
import { getAllPosts, getPost } from '@/lib/blog'
export const dynamicParams = false
export async function generateStaticParams() {
return (await getAllPosts()).map(({ slug }) => ({ slug }))
}
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const post = await getPost(slug)
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
</article>
)
}تلوين الشيفرة أثناء البناء
إضافة remark صغيرة تستبدل كل عقدة شيفرة بـ HTML ملوّن:
import hljs from 'highlight.js'
import { visit } from 'unist-util-visit'
export function highlightCode() {
return (tree: any) => {
visit(tree, 'code', (node: any, index, parent: any) => {
const lang = node.lang && hljs.getLanguage(node.lang) ? node.lang : null
const html = lang
? hljs.highlight(node.value, { language: lang }).value
: hljs.highlightAuto(node.value).value
parent.children[index!] = {
type: 'html',
value: `<pre><code class="hljs language-${lang ?? 'text'}">${html}</code></pre>`,
}
})
}
}أضفها قبل remarkHtml بـ .use(highlightCode)، واستورد سمة من highlight.js مثل highlight.js/styles/github-dark.css.
الصور والفيديو
يقدّم Next.js الملفات الثابتة من public/، لذا يجب أن تنتهي وسائط المقالات هناك. سكربت صغير يعمل قبل dev وbuild ينسخ كل ما ليس Markdown:
import fs from 'node:fs'
import path from 'node:path'
const SRC = 'content/blog'
const OUT = 'public/blog-media'
fs.rmSync(OUT, { recursive: true, force: true })
for (const slug of fs.readdirSync(SRC)) {
const dir = path.join(SRC, slug)
if (!fs.statSync(dir).isDirectory()) continue
for (const file of fs.readdirSync(dir)) {
if (file.endsWith('.md')) continue
fs.mkdirSync(path.join(OUT, slug), { recursive: true })
fs.copyFileSync(path.join(dir, file), path.join(OUT, slug, file))
}
}{
"scripts": {
"predev": "node scripts/copy-blog-media.mjs",
"prebuild": "node scripts/copy-blog-media.mjs"
}
}ثم في إضافة remark أخرى، أعد كتابة روابط الصور النسبية (./cover.jpg) إلى /blog-media/<slug>/cover.jpg. وما دمت هناك، حوّل الصور التي تشير إلى ملفات .mp4 أو .webm إلى عناصر <video controls>، وروابط YouTube إلى مقاطع مضمّنة؛ ويواصل الكتّاب استخدام صيغة الصور العادية في Markdown.
أضف public/blog-media إلى .gitignore؛ فهو مجلد مولَّد.
النشر
اربط المستودع بمستضيف للمواقع الثابتة (Cloudflare Pages أو Netlify أو Vercel)، مع npm run build أمرًا للبناء. وتصبح كتابة مقالة كالتالي:
- أنشئ مجلدًا و
index.md. - أودِع التعديل وادفعه.
- يبني المستضيف الموقع وينشره خلال دقائق.
إضافات لتحسين محركات البحث تستحق العناء
generateMetadataمع عنوان المقالة ومقتطفها ورابطها الأساسي (canonical) وصورة الغلاف كصورة Open Graph.- ملف
sitemap.tsيسرد كل المقالات. - بيانات JSON-LD من نوع
BlogPosting. - معرّفات للعناوين، ليمكن الربط بالأقسام.
يغطي كل ذلك مقال تحسين محركات البحث تقنيًا في Next.js App Router.
متى تتجاوزه
إذا كان عدة أشخاص غير تقنيين ينشرون يوميًا، ويحتاجون إلى جدولة المقالات أو مسارات تحرير ومراجعة، يبدأ نظام إدارة المحتوى بلا واجهة (headless CMS) في استرداد تكلفته. أما لموقع شخصي، أو استوديو، أو مدونة منتج تنشر بضع مقالات شهريًا، فمن الصعب التفوق على Markdown داخل Git.

