كيفية إنشاء مدونة معرض أعمال بسيطة باستخدام Next.js

دقائق القراءة: 13

مقدمة: لماذا تبني مدونة معرض أعمال خاصة بك؟

إذا كنت تنشر مقالاتك التقنية على أكثر من منصة، مثل LinkedIn أو Substack أو freeCodeCamp، فغالباً ستحتاج إلى مكان واحد يجمع كل أعمالك بطريقة احترافية وسهلة التصفح. وهنا تظهر فكرة إنشاء مدونة معرض أعمال تعرض مقالاتك وروابطها وصورها وتواريخ نشرها تلقائياً، من دون الحاجة إلى إدارة قاعدة بيانات أو تحديث الواجهة يدوياً كل مرة.

في هذا الدليل العملي، سنعيد بناء الفكرة باستخدام Next.js بأسلوب مرن وقابل للتوسّع. الهدف ليس فقط إنشاء مدونة، بل إنشاء نظام بسيط يسمح لك بإضافة رابط مقال جديد داخل ملف JSON، ثم ينجز الموقع بقية العمل تلقائياً عبر استخراج العنوان والوصف والصورة وتاريخ النشر من الصفحة نفسها.

واجهة أولية لمدونة معرض أعمال مبنية باستخدام نكست جي إس لعرض المقالات التقنية

المتطلبات الأساسية قبل البدء

حتى تتمكن من متابعة الشرح بسهولة، من الأفضل أن تكون لديك معرفة أساسية بالمفاهيم التالية:

  • HTTP وآلية إرسال الطلبات واستقبال الاستجابات.
  • بنية صفحات HTML.
  • تنسيق الواجهات باستخدام CSS.
  • البرمجة بلغة JavaScript.
  • فهم أولي لمكتبة React.

حتى إن لم تكن متمكناً بالكامل من هذه الأدوات، يمكنك الاستفادة من الفكرة العامة وطريقة تنظيم المشروع.

كيف تعمل مدونة معرض الأعمال؟

المشروع يعتمد على مزيج من Server Components وClient Components. من الناحية الظاهرية هو موقع واجهة أمامية، لكنه يحتاج إلى جلب بيانات من روابط خارجية. تنفيذ هذا الأمر من جهة العميل مباشرة يسبب مشكلة CORS، لأن المتصفح قد يمنع الوصول إلى تلك الصفحات.

لهذا السبب، تُنفَّذ عملية جلب البيانات على الخادم، ثم تُرسل النتائج الجاهزة إلى الواجهة.

مخطط يوضح انتقال البيانات في مشروع مدونة نكست جي إس بين الخادم والواجهة

الدالة fetchArticles() تعمل على الخادم، وتقوم بزيارة روابط المقالات، ثم تحلل محتوى الصفحة لاستخراج البيانات الوصفية.

مخطط يشرح وظيفة fetchArticles في استخراج بيانات المقالات من الروابط

بعد ذلك، تجمع الدالة البيانات من وسمات Open Graph ومن كائنات JSON-LD إن وُجدت، ثم تُرجع مصفوفة من الكائنات من النوع Article إلى الصفحة الرئيسية.

نتيجة معالجة بيانات المقالات وإرجاعها إلى الصفحة الرئيسية في تطبيق نكست جي إس

الصفحة الرئيسية تستدعي البيانات على الخادم، ثم تمررها إلى مكوّن عميل يتولى العرض والبحث والتصفية.

تركيب مكونات الصفحة الرئيسية وربطها ببيانات المقالات في مدونة معرض الأعمال

داخل مكوّن HomeClient يوجد مكوّنان أساسيان:

  • Hero: يعرض الرسالة التعريفية وشريط البحث.
  • MainBody: يعرض الوسوم وشبكة المقالات ويحتوي منطق التصفية.

تقسيم واجهة الصفحة إلى Hero وMainBody داخل المكون الرئيسي

أما بطاقات المقالات نفسها، فيتولى عرضها مكوّن ArticleCard الذي يستقبل قائمة المقالات بعد التصفية ويحوّل كل عنصر إلى بطاقة قابلة للنقر.

ما شكل كائن المقال داخل المشروع؟

يمثل كل مقال كائناً يطابق الواجهة Article التالية:

export interface Article {
  id: number;
  title?: string;
  description?: string;
  publishedDate?: string;
  url: string;
  imgUrl?: string;
  siteName?: string;
  tags?: string[];
}

هذه الواجهة تتضمن 8 خصائص، لكن الإلزام الحقيقي في البداية يكون غالباً للخاصيتين id وurl. عند إضافة الرابط في ملف JSON، يتولى الخادم زيارة الصفحة واستخراج بقية البيانات تلقائياً مثل العنوان والوصف والصورة وتاريخ النشر واسم المنصة.

مثال على بنية كائن Article المستخدم لتمثيل المقالات داخل المشروع

وتتضمن بطاقة المقال عادة العناصر التالية:

  • الصورة البارزة.
  • اسم المنصة الناشرة.
  • تاريخ النشر.
  • عنوان المقال.
  • وصف موجز.

أما الوسوم tags فلا تظهر داخل البطاقة مباشرة، لكنها أساسية في البحث والتصفية.

بطاقة مقال تعرض الصورة والعنوان والوصف وبيانات النشر في مدونة معرض الأعمال

كيف تعمل ميزة البحث وتصفية المقالات؟

وُضع المكوّنان Hero وMainBody داخل أب واحد حتى يسهل تمرير الحالة المشتركة بينهما، وتحديداً قيمة searchTerm. بهذه الطريقة، يمكن كتابة عبارة البحث داخل Hero ثم استخدامها مباشرة في MainBody لتصفية النتائج.

المنطق الأساسي يعتمد على useEffect() لمراقبة تغيّر:

  • articles
  • searchTerm
  • isActive
useEffect(() => {
  const anyTagActive = isActive.some((val) => val);

  const filtered = articles.filter((article) => {
    const searchMatch =
      article.title?.toLowerCase().includes(searchTerm.toLowerCase()) ||
      article.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
      article.tags?.some((tag) => tag.toLowerCase().includes(searchTerm.toLowerCase())) ||
      article.siteName?.toLowerCase().includes(searchTerm.toLowerCase()) ||
      article.publishedDate?.toLowerCase().includes(searchTerm.toLowerCase());

    const tagMatch = article.tags?.some((tag) => {
      const index = tags.indexOf(tag);
      return index !== -1 && isActive[index];
    }) || false;

    if (anyTagActive) {
      return tagMatch && searchMatch;
    }

    return searchMatch;
  });

  setFilteredArticles(filtered);
}, [articles, searchTerm, isActive]);

المنطق هنا بسيط وفعّال:

  • المتغير searchMatch يتحقق مما إذا كانت عبارة البحث موجودة في العنوان أو الوصف أو الوسوم أو اسم المنصة أو تاريخ النشر.
  • المتغير tagMatch يتحقق مما إذا كانت وسوم المقال تطابق الوسوم المفعّلة حالياً.
  • إذا كان هناك وسم واحد مفعّل على الأقل، فلا بد من تحقق الشرطين معاً.
  • إذا لم تكن هناك وسوم مفعلة، فيُكتفى بمطابقة البحث النصي.

حالة الوسوم النشطة تُدار بمصفوفة من القيم المنطقية عبر useState():

const [isActive, setIsActive] = useState(tags.map(() => false));

وعند النقر على أي وسم، يجري قلب حالته من false إلى true أو العكس.

const newIsActive = [...isActive];
newIsActive[index] = !newIsActive[index];
setIsActive(newIsActive);

بنية المشروع ومجلداته

تنظيم الملفات هنا مهم جداً لأنه يسهل الصيانة والتوسعة لاحقاً.

هيكل ملفات مشروع نكست جي إس لمدونة معرض الأعمال

يتكوّن المشروع في صورته الأساسية من:

  • مجلد public للصور والأيقونات.
  • مجلد src ويضم:
  • app: الصفحات والملفات العامة مثل layout.tsx وpage.tsx.
  • components: مكوّنات الواجهة مثل الشريط العلوي والبطاقات والتذييل.
  • utils: الدوال المساعدة المسؤولة عن جلب البيانات وتحليلها.

كما يحتوي المشروع على ملف articles.json الذي يشكل نقطة البداية لإضافة المقالات الجديدة.

خطوات بناء المدونة باستخدام Next.js

1) تثبيت مشروع Next.js

أنشئ مشروعاً جديداً عبر الأمر التالي:

npx create-next-app@latest

خطوات إنشاء مشروع جديد باستخدام create-next-app

2) تثبيت الاعتمادات المطلوبة

بعد إنشاء المشروع، شغّل الخادم المحلي ثم أضف المكتبات اللازمة. في هذا المثال نحتاج إلى:

  • lucide-react للأيقونات.
  • cheerio لتحليل صفحات HTML واستخراج البيانات منها.
npm install lucide-react
npm install cheerio

تشغيل مشروع نكست جي إس محلياً بعد الإنشاء وتثبيت الاعتمادات

3) تحديث بيانات الصفحة الأساسية

بدلاً من تعديل index.html كما في المشاريع التقليدية، يستخدم Next.js كائن Metadata داخل ملف layout.tsx لتحديد عنوان الصفحة والوصف وغير ذلك.

يمكنك مثلاً تغيير العنوان الافتراضي من Create Next App إلى اسم مدونتك الخاصة.

تعديل عنوان ووصف الصفحة في ملف layout داخل مشروع نكست جي إس

4) إنشاء المكوّنات الأساسية

داخل مجلد src/components، أنشئ الملفات التالية:

  • Navbar
  • Hero
  • MainBody
  • ArticleCard
  • Footer

إنشاء مكونات الواجهة الأساسية داخل مجلد components في المشروع

مثال على مكوّن Navbar:

export default function Navbar() {
  return (
    <>
      <div className="text-3xl md:text-base flex w-[100vw] md:w-[98.2vw] lg:w-[98.8vw] h-[60px] bg-black text-white px-0 md:px-7 md:py-2 items-center justify-center md:justify-between">
        <h1 className="font-bold">CHIDIADI ANYANWU</h1>
        <div className="hidden md:block flex space-x-4">
          <a href="/" className="hover:text-gray-400">Blog</a>
          <a href="/about" className="hover:text-gray-400">About</a>
        </div>
      </div>
    </>
  );
}

ومثال على مكوّن Hero الذي يحتوي شريط البحث:

"use client";
import { Search } from 'lucide-react';
import { useState } from 'react';

interface HeroProps {
  searchTerm: string;
  setSearchTerm: React.Dispatch<React.SetStateAction<string>>;
}

export default function Hero({ searchTerm, setSearchTerm }: HeroProps) {
  const [buttonColor, setButtonColor] = useState('');

  return (
    <div className="bg-[url('/img-one-1.jpg')] bg-cover bg-center bg-no-repeat flex flex-col items-center justify-center h-[400px] relative">
      <div className="absolute inset-0 bg-black opacity-60"></div>
      <h1 className="text-4xl text-white font-bold text-center z-10">My Portfolio Blog</h1>
      <div id="searchbar" className="h-9xl mt-4 flex align-items-center justify-center w-full">
        <form
          onSubmit={(e) => {
            e.preventDefault();
            setSearchTerm(searchTerm);
          }}
          className="group mt-4 relative w-[70%] md:w-[50%]"
        >
          <input
            value={searchTerm}
            onChange={(e) => setSearchTerm(e.target.value)}
            onFocus={() => setButtonColor(' bg-blue-500 ')}
            onBlur={() => setButtonColor('')}
            type="search"
            placeholder="Search Chidiadi's articles"
            className="h-[50px] w-full px-[48px] border-3 border-blue-300 rounded-[25px] focus:outline-none focus:border-blue-500 text-black bg-white"
          />
          <button className={`h-[42px] w-[42px] absolute right-0 mr-1.5 mt-1 rounded-[50%] bg-blue-300 ${buttonColor}`}>
            <Search className="m-auto text-white" />
          </button>
        </form>
      </div>
    </div>
  );
}

مكوّن MainBody يتولى إظهار الوسوم والمقالات المفلترة:

"use client";
import { useEffect, useState } from 'react';
import ArticleCard, { Article } from './ArticleCard';

interface MainBodyProps {
  searchTerm: string;
  articles: Article[];
}

export default function MainBody({ searchTerm, articles }: MainBodyProps) {
  const [filteredArticles, setFilteredArticles] = useState<Article[]>([]);
  const tags = ["Networking", "Cloud", "DevOps", "Web Dev", "Cybersecurity"];
  const [isActive, setIsActive] = useState(tags.map(() => false));

  useEffect(() => {
    const anyTagActive = isActive.some((val) => val);
    const filtered = articles.filter((article) => {
      const searchMatch =
        article.title?.toLowerCase().includes(searchTerm.toLowerCase()) ||
        article.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
        article.tags?.some((tag) => tag.toLowerCase().includes(searchTerm.toLowerCase())) ||
        article.siteName?.toLowerCase().includes(searchTerm.toLowerCase()) ||
        article.publishedDate?.toLowerCase().includes(searchTerm.toLowerCase());

      const tagMatch = article.tags?.some((tag) => {
        const index = tags.indexOf(tag);
        return index !== -1 && isActive[index];
      }) || false;

      if (anyTagActive) {
        return tagMatch && searchMatch;
      }

      return searchMatch;
    });

    setFilteredArticles(filtered);
  }, [articles, searchTerm, isActive]);

  return (
    <div className='scroll-smooth'>
      <div id="tags" className="flex w-full h-[200px] md:h-[60px] justify-center gap-5 py-4 flex-wrap max-w-[100vw] scroll-smooth">
        {tags.map((tag, index) => (
          <p
            key={index}
            onClick={() => {
              const newIsActive = [...isActive];
              newIsActive[index] = !newIsActive[index];
              setIsActive(newIsActive);
            }}
          >
            {tag}
          </p>
        ))}
      </div>
      <div id="articlegrid" className="w-[100vw] md:w-[98vw] grid gap-2 grid-cols-1 md:grid-cols-2 xl:grid-cols-3 mt-5 px-3 py-3">
        <ArticleCard articles={filteredArticles} />
      </div>
    </div>
  );
}

أما مكوّن ArticleCard فيحوّل كل مقال إلى بطاقة عرض:

import Image from 'next/image';

export interface Article {
  id: number;
  title?: string;
  description?: string;
  publishedDate?: string;
  url: string;
  imgUrl?: string;
  siteName?: string;
  tags?: string[];
}

interface ArticleProps {
  articles: Article[];
}

const ArticleCard = ({ articles }: ArticleProps) => {
  return (
    <>
      {articles
        ? articles.map((item, id) => (
            <a key={id} href={item.url} className='max-w-[350px] mx-auto mb-5'>
              <div className="sm:w-[350px] hover:brightness-70">
                <Image
                  src={item.imgUrl || '/img-2.jpg'}
                  alt={item.title || 'Article Image'}
                  width={350}
                  height={400}
                  className="object-cover rounded-[10px]"
                />
                <div className="flex h-[43px] text-[14px] text-gray-500 gap-2">
                  <p>{item.siteName}</p>
                  <div className="h-1 w-1 bg-gray-500 rounded-full mt-auto mb-auto"></div>
                  <p>{item.publishedDate}</p>
                </div>
                <h1 className="font-bold text-base md:text-3xl">{item.title}</h1>
                <br />
                <p className='w-full md:w-[350px]'>{item.description}</p>
              </div>
            </a>
          ))
        : Array(6).fill(0).map((_, id) => (
            <div key={id} className="w-full md:w-[350px] h-[350px] bg-gray-500 mx-auto mb-5 rounded-[10px] animate-pulse"></div>
          ))}
    </>
  );
};

export default ArticleCard;

ومن الجيد أيضاً استخدام Image من Next.js بدلاً من عنصر img التقليدي لأسباب تتعلق بالأداء وتحسين الصور.

5) ضبط مصادر الصور الخارجية في ملف الإعدادات

عند استخدام next/image مع صور خارجية، يجب تعريف النطاقات المسموح بها داخل ملف next.config.ts.

تكوين remotePatterns للسماح بتحميل الصور الخارجية في نكست جي إس

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "licdn.com",
        pathname: "/**"
      }
    ]
  }
};

export default nextConfig;

تأكد من أن حقول protocol وhostname ليست فارغة، لأن ذلك سيؤدي إلى أخطاء أثناء التشغيل أو البناء.

6) تثبيت المكوّنات العامة داخل التخطيط الرئيسي

المكوّنات التي تظهر في كل الصفحات، مثل الشريط العلوي والتذييل، من الأنسب وضعها داخل layout.tsx:

وضع مكونات Navbar وFooter داخل ملف layout الرئيسي

<body className={`${geistSans.variable} ${geistMono.variable} antialiased scroll-smooth`}>
  <Navbar />
  {children}
  <Footer />
</body>

المتغير {children} هو المكان الذي تُحقن فيه صفحات التطبيق المختلفة.

بعد ذلك، أنشئ مكوّن HomeClient لربط البحث بعرض المقالات:

'use client';
import { useState } from 'react';
import Hero from '../components/hero';
import MainBody from '../components/mainbody';
import { Article } from '../components/ArticleCard';

interface Props {
  initialArticles: Article[];
}

export default function HomeClient({ initialArticles }: Props) {
  const [searchTerm, setSearchTerm] = useState<string>('');
  const [articles] = useState<Article[]>(initialArticles);

  return (
    <div>
      <Hero searchTerm={searchTerm} setSearchTerm={setSearchTerm} />
      <MainBody searchTerm={searchTerm} articles={articles} />
    </div>
  );
}

ثم استدعِ هذا المكوّن داخل page.tsx بعد جلب البيانات من الخادم:

import { fetchArticles } from '../utils/fetchArticles';
import HomeClient from './HomeClient';

export const revalidate = 3600;

export default async function HomePage() {
  const articles = await fetchArticles();
  return <HomeClient initialArticles={articles} />;
}

السطر revalidate = 3600 يعني أن الموقع يعيد جلب البيانات كل ساعة، بدلاً من إعادة الطلب عند كل زيارة. هذا يحسن الأداء ويقلل زمن الانتظار للمستخدم.

7) إنشاء صفحة التعريف

يمكنك أيضاً إضافة صفحة About بسيطة لتعريف الزوار بك وبروابطك المهنية:

import Image from "next/image";

export default function About() {
  return (
    <>
      <div className="flex items-center justify-center">
        <div className="margin-auto w-[90vw] md:w-[60vw] lg:w-[50vw] h-[450px] hover:bg-gray-100 border-1 md:border-2 border-gray-200 shadow-sm flex flex-wrap items-center justify-center gap-2 mt-10 mb-10 rounded-lg">
          <Image src="/MyPhotoChidiadi.jpg" alt="Avatar" className="rounded-[50%] h-30 w-30" width={120} height={120} />
          <div className="w-[90%] mx-auto">
            <h1 className="text-xl text-center my-1 font-bold">About Me</h1>
            <p className="text-justify my-3">
              My name is Chidiadi Anyanwu. I love breaking down complex concepts.
            </p>
          </div>
        </div>
      </div>
    </>
  );
}

8) إنشاء مجلد الأدوات المساعدة

داخل utils، ستضع الدوال المسؤولة عن قراءة المقالات وتحليلها واستخراج خصائصها المختلفة.

مجلد الأدوات المساعدة الذي يحتوي على دوال استخراج بيانات المقالات

الدالة المحورية: fetchArticles()

هذه الدالة هي قلب المشروع الحقيقي. وظيفتها قراءة ملف articles.json، ثم المرور على الروابط، وجلب محتوى كل صفحة، وتحويله إلى كائن Article.

import { getPublishedDate } from './getPublishedDate';
import { getTitle } from './getTitle';
import { getImageURL } from './getImageURL';
import { getDescription } from './getDescription';
import { getPlatform } from './getPlatform';
import articleFile from '../app/articles.json';
import { Article } from '../components/ArticleCard';
import * as cheerio from 'cheerio';

export async function fetchArticles(): Promise<Article[]> {
  const results = await Promise.all(
    articleFile.articles.map(async (item) => {
      if (!item.url || typeof item.url !== 'string' || item.url.trim() === '') {
        return null;
      }

      let data;
      try {
        const response = await fetch(item.url, {
          headers: {
            'User-Agent': 'Mozilla/5.0',
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
            'Accept-Language': 'en-US,en;q=0.5',
            'Referer': 'https://www.google.com/'
          }
        });

        if (!response.ok) {
          throw new Error(`HTTP error! Status: ${response.status}`);
        }

        const html = await response.text();
        const $ = cheerio.load(html);
        const jsonScript = $('script[type="application/ld+json"]').html();

        if (!jsonScript) {
          throw new Error('No JSON-LD script found on page');
        }

        const metadata = JSON.parse(jsonScript);
        data = { metadata, html };
      } catch (error) {
        return null;
      }

      if (
        (getTitle(data) && getDescription(data) && getPublishedDate(data) && getImageURL(data) && getPlatform(data)) ||
        (item.title && item.description && item.image)
      ) {
        return {
          ...item,
          id: item.id ?? 0,
          tags: item.tags ?? [],
          title: getTitle(data) || item.title || 'No title',
          description: item.description || getDescription(data) || 'No description',
          publishedDate: getPublishedDate(data) ?? 'No date',
          imgUrl: getImageURL(data) || item.image || '/img-2.jpg',
          siteName: getPlatform(data) || data.metadata?.publisher?.name || 'Unknown site',
          url: item.url || ''
        } as Article;
      }

      return null;
    })
  );

  const filteredResults = results.filter((article): article is Article => article !== null);
  const sortedResults = filteredResults.sort((a, b) => {
    const dateA = new Date(a.publishedDate || '').getTime();
    const dateB = new Date(b.publishedDate || '').getTime();
    return dateB - dateA;
  });

  return sortedResults;
}

تمر الدالة بالمراحل التالية:

  1. قراءة قائمة المقالات من الملف.
  2. التحقق من صحة كل رابط.
  3. إرسال طلب fetch() إلى الصفحة.
  4. تحميل محتوى HTML.
  5. تحليل الصفحة باستخدام cheerio.
  6. استخراج بيانات JSON-LD وOpen Graph.
  7. بناء كائن موحد من النوع Article.
  8. استبعاد العناصر الفاشلة وترتيب النتائج من الأحدث إلى الأقدم.

الدوال المساعدة لاستخراج البيانات

استخراج العنوان عبر getTitle()

import * as cheerio from 'cheerio';

export function getTitle(data: any): string {
  if (!data) return 'Title Loading...';
  if (data?.html) {
    const $ = cheerio.load(data?.html);
    const ogTitle = $('meta[property="og:title"]').attr('content') || $('title').text();
    return ogTitle;
  }
  return 'The Title of The Article';
}

تعتمد هذه الدالة أولاً على وسم og:title، ثم تستخدم عنصر <title> كخيار احتياطي.

استخراج الوصف عبر getDescription()

import * as cheerio from 'cheerio';

export function getDescription(data: any): string {
  if (!data) return 'Description Loading...';
  if (data?.metadata || data?.html) {
    const $ = cheerio.load(data?.html || '');
    const description =
      data?.metadata?.description ??
      $('meta[property="og:description"]').attr('content') ??
      'No description found';
    return description;
  }
  return 'No description found';
}

استخراج الرابط عبر getURL()

import * as cheerio from 'cheerio';

export function getURL(data: any): string {
  if (!data) return 'url';
  if (data?.metadata || data?.html) {
    const $ = cheerio.load(data?.html);
    const url = data?.metadata.url || $('meta[property="og:url"]').attr('content');
    return url;
  }
  return 'url';
}

استخراج اسم المنصة عبر getPlatform()

import { getURL } from './getURL';

export function getPlatform(data: any): string {
  if (!data) return 'Platform1';
  const url = getURL(data);
  if (data?.html) {
    const regex = /^(?:https?:\/\/)?(?:www\.)?([^\/\n]+)\.(?:[a-zA-Z]{2,})/;
    const platform = url.match(regex);
    return platform?.[1].toUpperCase() || 'Platform2';
  }
  return 'Platform3';
}

هذه الدالة تستخدم Regex لاستخراج اسم النطاق الرئيسي من الرابط. الحل ليس مثالياً مع كل النطاقات الفرعية، لكنه عملي في كثير من الحالات.

النمط المستخدم هو:

/^(?:https?:\/\/)?(?:www\.)?([^\/\n]+)\.(?:[a-zA-Z]{2,})/

الفكرة هنا هي التقاط الجزء الأكثر دلالة من اسم الموقع وتحويله إلى أحرف كبيرة لتظهر المنصة بوضوح داخل البطاقة.

شرح عملي لاستخراج اسم المنصة من رابط المقال باستخدام التعبيرات النمطية

استخراج صورة الغلاف عبر getImageURL()

import * as cheerio from 'cheerio';

export function getImageURL(data: any): string {
  if (!data) return '/img-2.jpg';
  if (data?.metadata || data?.html) {
    const $ = cheerio.load(data?.html);
    const ogImage = $('meta[property="og:image"]').attr('content') || data?.metadata.image;
    return ogImage || '/img-2.jpg';
  }
  return '/img-2.jpg';
}

استخراج تاريخ النشر عبر getPublishedDate()

import * as cheerio from 'cheerio';

export function getPublishedDate(data: any): string {
  if (!data) return 'Date';
  const publishedDate = data?.metadata?.datePublished;

  if (publishedDate) {
    const options: Intl.DateTimeFormatOptions = {
      year: 'numeric',
      month: 'long',
      day: 'numeric'
    };
    return new Date(publishedDate).toLocaleDateString('en-US', options);
  }

  if (data?.html) {
    const $ = cheerio.load(data?.html);
    const ogPublishedTime =
      $('meta[property="article:published_time"]').attr('content') ||
      $('meta[property="og:published_time"]').attr('content') ||
      $('meta[name="pubdate"]').attr('content');

    if (ogPublishedTime) {
      const options: Intl.DateTimeFormatOptions = {
        year: 'numeric',
        month: 'long',
        day: 'numeric'
      };
      return new Date(ogPublishedTime).toLocaleDateString('en-US', options);
    }
  }

  return 'Date';
}

تكمن أهمية هذه الدالة في تحويل التاريخ من صيغة ISO 8601 إلى صيغة أكثر قراءة للمستخدم.

ملف JSON: نقطة البداية لإضافة المقالات

الملف articles.json هو أبسط طريقة لتغذية الموقع بالمحتوى. بدلاً من لوحة تحكم أو قاعدة بيانات، يكفي إضافة رابط جديد هنا.

ملف articles.json المستخدم لإضافة روابط المقالات ووسومها في المشروع

{
  "articles": [
    {
      "id": 1,
      "url": "https://thenetworkbits.substack.com/p/an-overview-of-json",
      "tags": ["Web Dev", "DevOps", "Cloud"],
      "title": "",
      "description": "",
      "image": ""
    },
    {
      "id": 2,
      "url": "https://websecuritylab.org/how-safe-is-public-wi-fi-a-network-engineer-explains/",
      "tags": ["Networking", "Cybersecurity"],
      "title": "",
      "description": "",
      "image": ""
    },
    {
      "id": 3,
      "url": "https://www.freecodecamp.org/news/automate-cicd-with-github-actions-streamline-workflow/",
      "tags": ["DevOps"],
      "title": "",
      "description": "",
      "image": ""
    }
  ]
}

من الناحية العملية، يكفي وجود id وurl، لكن الاحتفاظ بالخصائص الأخرى يتيح لك تجاوز البيانات التلقائية إذا كانت بعض المنصات لا توفر وصفاً جيداً أو تمنع الوصول إلى بياناتها.

اللمسات النهائية قبل الإطلاق

بعد اكتمال البنية الأساسية، أضف:

  • favicon خاصاً بالموقع.
  • الصور الثابتة داخل مجلد public.
  • أي تحسينات بصرية أو نصية تلائم هويتك الشخصية.

يمكن تعريف الأيقونة داخل التخطيط الرئيسي باستخدام:

<link rel="icon" href="/favicon.ico" sizes="any" />

ثم شغّل المشروع محلياً:

npm run dev

النتيجة النهائية للواجهة الرئيسية بعد تشغيل مدونة معرض الأعمال محلياً واجهة عرض المقالات مع شبكة البطاقات وشريط البحث في المشروع صفحة داخلية أو مظهر نهائي لمدونة تقنية مبنية بإطار Next.js

نصائح تقنية لتحسين المشروع لمحركات البحث وAdSense

  • اكتب وصفاً تحريرياً فريداً لكل صفحة، ولا تعتمد فقط على البيانات المستخرجة آلياً.
  • استخدم عناوين واضحة وغنية بالكلمات المفتاحية بشكل طبيعي.
  • احرص على ضغط الصور وتحسين Core Web Vitals.
  • وفّر صفحة About وصفحة تواصل وسياسة خصوصية لزيادة الموثوقية.
  • لا تملأ الصفحة بعناصر منسوخة من منصات أخرى بلا قيمة مضافة؛ أضف سياقاً وتنظيماً وتجربة تصفح أفضل.

الخلاصة التقنية

بناء مدونة معرض أعمال باستخدام Next.js مع ملف JSON بسيط ودوال لاستخراج بيانات Open Graph وJSON-LD هو حل ذكي وخفيف لمن يريد جمع أعماله في مكان واحد دون تعقيد. من الناحية التقنية، هذه الفكرة ممتازة للمشاريع الشخصية لأنها تقلل الاعتماد على قواعد البيانات، وتسمح بالتوسعة لاحقاً نحو RSS أو CMS إذا لزم الأمر. والأهم من ذلك أنها تقدم تجربة منظمة للمستخدم، وهي نقطة أساسية لأي موقع يسعى إلى تحسين الظهور في نتائج البحث وتحقيق قبول أفضل في Google AdSense.

اترك تعليقاً

لن يتم نشر عنوان بريدك الإلكتروني. الحقول الإلزامية مشار إليها بـ *