هذا الموقع

كيف يُبنى هذا الموقع، وما الموجود في المستودع الذي يُنتجه.

المستودع

762 ملفًا عبر 284 مجلدات
apps
web
src
content.config.ts
.gitignore
AGENTS.md
astro.config.mjs
eslint.config.js
package.json
README.md
tsconfig.json
turbo.json
vitest.config.ts
.gitignore
.mcp.json
.npmrc
.nvmrc
.prettierignore
AGENTS.md
CLAUDE.md
package.json
pnpm-lock.yaml
pnpm-workspace.yaml
prettier.config.js
README.md
renovate.json
turbo.json

هذا الموقع عبارة عن معرض أعمال ثابت بأربع لغات مبني بـ Astro، إلى جانب نظام إدارة محتوى Strapi يغذّيه بالمحتوى. كل ما تراه ينتج من مستودع واحد، والشجرة على الجانب هي ذلك المستودع — مولَّدة من git ls-files، فهي تسرد بالضبط ما هو مُدرَج في النسخ، ولا شيء غير ذلك.

تطبيقان، مساحة عمل واحدة

هذا مستودع أحادي (monorepo) يعمل بـ pnpm و Turbo، يحتوي تطبيقين وثلاث حزم إعدادات مشتركة وحزمة واحدة لتوليد الأصول:

  • apps/web — موقع Astro 7 / React 19 / Tailwind v4 الذي تقرأه الآن.
  • apps/admin — نظام إدارة محتوى Strapi 5 (مثبَّت على 5.52.3، فوق Postgres).
  • packages/typescript-config وpackages/eslint-config و packages/tailwind-config — أسس TypeScript والتدقيق ورموز التصميم المشتركة.
  • packages/logo-assets — يرسم شعار الموقع بصيغتَي SVG و PNG. يقرأ ألوانه من packages/tailwind-config/theme.css بدلًا من أن يحتفظ بأي منها، ولا يعتمد عليه شيء في عملية البناء.

التطبيقان لا يتشاركان شيئًا سوى البيانات، وذلك عن قصد. يشغّل apps/admin لوحة إدارته الخاصة بـ React 18، وملف tsconfig.json الخاص به، وإعداد Prettier الخاص به؛ وهو لا يستورد مكوّنات ولا أنواعًا من apps/web، ولا يستخدم اختصار المسار $/* الخاص بالموقع. العقد الوحيد بينهما عقد مولَّد: pnpm --filter admin generate يقرأ المخطَّط من Strapi قيد التشغيل ويكتب عميلًا مُنمَّطًا في apps/web/src/.generated/strapi-client/، يستورده الموقع عبر الاختصار $strapi. غيّر نوع محتوى، أعد التوليد، وأخطاء الأنواع تخبرك بما يجب إصلاحه. لا شيء آخر يعبر الحدّ.

مهمة Turbo صغيرة بشكل مقصود. يعلن turbo.json ستّ مهام — build و lint و lint:fix و typecheck و test و dev — وهي في معظمها لا تفعل سوى ترتيب نفسها خلف اعتماديات مساحة العمل (dependsOn: ["^build"] وما شابه). أما dev فمعلّمة بـ cache: false, persistent: true. وما تُبصِّمه كل مهمة هو الجزء الذي فيه التفكير، ونصيب تطبيق الويب منه يقع في apps/web/turbo.json: متغيّرات البيئة التي تغيّر ناتجه، و"cache": false على astro check. ولا يزال Turbo لا يعرف شيئًا عن Astro ولا عن Strapi؛ فهو يجدول ويخزّن مؤقتًا، وكل تطبيق يملك سكربتاته الخاصة.

ثابت بحكم البناء

لا يهيّئ apps/web/astro.config.mjs أي مُهايئ (adapter). هذا الإغفال الواحد هو القرار الذي يشكّل كل ما تبقّى: الناتج HTML مولَّد مسبقًا، يُزامَن إلى S3 ويُقدَّم عبر CloudFront. لا يوجد خادم عند وقت الطلب، وبالتالي كل قراءة للبيانات تحدث في وقت البناء — عبر مرحلة مزامنة تعمل مرة واحدة الآن، قبل تصيير أي صفحة: تستدعي مُحمِّلات مجموعات المحتوى العميل المولَّد، ويستدعي العميل Strapi لكل اللغات الأربع، وتحطّ النتائج في مخزن محتوى Astro. الصفحات لا تجلب شيئًا بنفسها بعد الآن؛ إنما تقرأ من ذلك المخزن، والجواب ما زال يُخبَز في النهاية داخل HTML.

النتائج هي الجزء المثير. تعديلات المحتوى ليست فورية — فهي تحتاج إعادة بناء، ولهذا نبت في نظام إدارة المحتوى زرّ Deploy. والأسرار لا تصل متصفحًا أبدًا، لأن العملية الوحيدة التي تحملها هي البناء. والإعداد متيقّظ بشأن هذا الحدّ: تُقرأ CDN_URL وIMAGE_DOMAINS عبر loadEnv الخاص بـ Vite وكلتاهما اختيارية، بينما تُعلَن STRAPI_BASE_URL و STRAPI_API_TOKEN و SITE_URL في env.schema الخاص بـ Astro تحت context: 'server'، والرمز المميز إضافةً إلى ذلك بـ access: 'secret'. وقد أُغلق منفذ نجاة وتُرك آخر مفتوحًا عن قصد: فالإعداد لم يبقَ يرمي خطأً بغياب CDN_URL، فلم تبقَ مجموعة الاختبارات تحتاج أي بيانات اعتماد، لكن astro check والبناء الحقيقي ما زال كلٌّ منهما يحتاج نظام إدارة محتوى يمكن الوصول إليه، ويزوّد CI بيانات اعتماد حقيقية من سرّ بدل التراجع إلى مخزن محتوى فارغ.

وثمّة أمران يخاطبان نظام إدارة المحتوى من المتصفّح فعلًا، وهما الاستثناء الذي يوضّح القاعدة. فـPOST /api/contact يحمل استمارة التواصل في الصفحة الرئيسة، و — منذ حلول تسجيل الدخول — تجيب نقاط نهاية الجلسة تحت /api/auth/* ومعها /api/users/me صفحات /auth/* والترويسة. ولا يستطيع أيٌّ منهما حمل سرّ، لأن صفحةً مبنيةً بناءً ثابتًا لا تملك مكانًا تحفظه فيه: فمسار التواصل عام، تحرسه مصيدة عسل ومحدِّد معدّل بدل رمز مميّز، وبيانُ اعتماد الجلسة كعكةٌ httpOnly على أصل نظام إدارة المحتوى لا يقرؤها أي سكربت، تُستبدَل برمز وصول عمره عشر دقائق يقيم في متغيّر على نطاق الوحدة ولا يُكتب في أي مخزن. فالموقع يحمل الآن جلسةً لم يرها أي بناء قطّ. وجزيرة الترويسة تسأل من أنت بعد تحميل الصفحة، ولا تصيّر شيئًا على الإطلاق إن كان الجواب: لا أحد — وهو جواب كل زائر تقريبًا.

ويفعل نظام إدارة المحتوى أمرًا لا يشهده بناءٌ ولا متصفّح: إنه يبعث البريد. فالإرسال الواحد من استمارة التواصل يخلّف اليوم رسالتين — إشعارَ المشغّل، وإقرارًا للزائر بأيٍّ من اللغات الأربع كتب بها. وهاتان، مع رسالة إخبارية ورسالتَي المصادقة، عشرة قوالب في المجموع، وكلٌّ منها مكتوب باليد. وكان مولِّد في apps/admin/scripts/email-templates/ يؤلّف العشرة كلَّها من هيكل واحد، فتبدو إعادة تعيين كلمة المرور والردّ الآلي وكأنهما من الموقع نفسه؛ وقد زال هو وأثراه المودَعان في git، لأن نسخة مولَّدة من شيء يحفظه نظام إدارة المحتوى أصلًا مصدرُ حقيقة ثانٍ ينحرف عنه. وهي تصل إلى Strapi بمسارين مختلفين لأنها شيئان مختلفان: فالثمانية المترجَمة صفوفٌ في ملحق Email Designer تُكتب في تبويب Custom الخاص به، أما رسالتا المصادقة فليستا صفَّين إطلاقًا، بل تُلصَقان يدويًا في مخزن الملحق من شاشة إعدادات Strapi نفسها — إذ لا سبيل لمحرِّر الملحق إلى الحفاظ على markup مكتوب بيد، فحفظُ أيّ قالب هناك يعيد تصدير مستند Unlayer فوقه دومًا. وapps/admin/src/lib/email-templates.ts هو ما بقي، وهو اليوم الملفُّ الوحيد الذي يجب إبقاؤه متوافقًا مع نظام إدارة المحتوى باليد: فهو يحمل معرّفًا مرجعيًا لكل لغة، وأيّ قالب يُنشأ في الملحق بلا templateReferenceId المطابق يفشل صامتًا — فمعرّف مرجعي مجهول يسجّل سطرًا ويعود، والزائر لا يسمع جوابًا قطّ. وحراسةُ ذلك هي أيضًا ما أدخل أخيرًا مُشغّلَ اختبارات إلى apps/admin: مجموعة Vitest تحت apps/admin/tests/، حيث لم يكن هناك مُشغّل على الإطلاق.

من الدفع إلى شبكة التوزيع

flowchart TB
  renovate["Renovate، كل اثنين"] --> pr["طلب سحب"]
  pr --> checks["تنسيق وفحص وأنواع واختبارات وبناء الويب"]
  checks --> push["دفع إلى master"]
  push --> detect["اكتشاف المسارات المتغيرة"]
  detect --> cms["بناء Strapi، ومزامنته، وإعادة تشغيله"]
  cms --> web["بناء Astro، ومزامنته مع S3"]
  web --> cdn["إبطال ذاكرة التخزين المؤقت لشبكة التوزيع"]
  widget["أداة النشر في Strapi"] --> web

الفحوص والنشر سيرا عمل منفصلان، وهذا هو القرار الوحيد الذي تقوم عليه بساطة بقيّة الخطّ. يعمل .github/workflows/ci.yml على طلبات السحب ويحتوي مهمة واحدة، Checks: فحص حداثة شجرة الملفات، ثم format:check، ثم turbo run lint typecheck test --continue، ثم بناء الموقع. ويعمل .github/workflows/deploy.yml على الدفع إلى master، فيحدّد أهداف النشر ويستدعي cd-cms.yml ثم cd-web.yml. ولا يؤدّي أحدهما عمل الآخر، فلكلٍّ مُشغِّل واحد، وقد زالت بوّابة التجميع وسيور العمل القابلة لإعادة الاستخدام لكل نطاق ومعظم شروط انتشار التخطّي التي كانت تقيم هنا.

ولا يملك ci.yml مُشغِّل push عن قصد. فـpull_request يفحص refs/pull/N/merge — أي الرأس مدموجًا في القاعدة — فما لم تتحرّك القاعدة، فإن الشجرة التي تحلّ في master هي الشجرة التي فُحصت أصلًا. وإعادة فحصها هناك لا تُثبت شيئًا، وضمانُ أن القاعدة لا تتحرّك من تحت طلب سحب قديم ضبطٌ في حماية الفرع (اطلب أن تكون الفروع محدَّثة، أو طابور دمج) لا شيء يستطيع YAML التعبير عنه. ومن ثمّ لا يشغّل deploy.yml أي فحوص.

ولا يُرشَّح بالمسارات إلا نشر نظام إدارة المحتوى، عبر dorny/paths-filter، لأنه رفع بحجم ~1 غيغابايت وإعادة تشغيل لـStrapi. أما نشر الموقع فيعمل عند كل دفع، وهذا ليس تهاونًا: إذ ينشر /about/this-website شجرةً مبنيّة من git ls-files من جذر المستودع، فأي إيداع يلمس docs/ وحدها يغيّر صفحة منشورة. وترشيحه على apps/web/** كان يسلّم شجرة قديمة في صمت. وفي المرشِّح ثغرة تستحق المعرفة — workflow_dispatch لا يملك أساسًا للمقارنة، فكان سيقارن الفرع بنفسه ويتخطّى كل شيء، محوّلًا إعادة النشر اليدوية إلى لا-عمل صامت. ولذلك تُتخطّى خطوة الترشيح عند الإرسال اليدوي، وتفرض خطوةُ حسمٍ هدفَ نظام إدارة المحتوى، وهذا ما يجعل «أرسل Deploy على master» زرَّ إعادة نشر كل شيء.

ويأتي نظام إدارة المحتوى قبل الموقع لأن الصفحات المُصيَّرة مسبقًا تخبز استجابات Strapi في الخَرج الساكن: فتغييرٌ في المحتوى دون إعادة بناء للموقع يترك الموقع الحيّ قديمًا. وتنتظر مهمة الموقع مهمةَ نظام إدارة المحتوى عند النجاح أو التخطّي، وهذا هو شرط انتشار التخطّي الوحيد الباقي في الخطّ — فـ!cancelled() هو ما يسمح لمهمة أن ترى سابقةً متخطَّاة بدل أن تُتخطّى معها، بثمنِ إعادة ذكر البوّابات التي يطرحها. ثم يرفع نشر الموقع على ثلاث مرّات — أصولٌ ثابتة مبصومة، ثم صفحات مع --delete، ثم تقليمٌ للأصول القديمة — ويُبطل ذاكرة CloudFront، ويطلب مسارين عبر شبكة التوزيع، لأن إيصال البايتات إلى دلو ليس هو خدمةَ ذلك الدلو.

السهم الأخير الداخل إلى نشر الموقع يأتي من Strapi نفسه. إضافة محلية في apps/admin/src/plugins/web-deploy/ تضيف وِدجت على الصفحة الرئيسية وصفحة في الشريط الجانبي تُرسِلان cd-web.yml عبر واجهة GitHub REST مع dispatch-id عشوائي. يعيد cd-web.yml صدى ذلك المعرِّف في run-name الخاص به، وتجد الإضافة التشغيل بذلك الرمز — ربط حتمي بدل التخمين داخل نافذة زمنية.

وثمّة سير عمل خامس لا ينتمي إلى أيٍّ من النصفين. فـrenovate.yml يعمل مستضافًا ذاتيًا على مؤقّت يوم الاثنين، وهو الوحيد الذي يكتب في المستودع بدل أن ينشر منه، ولهذا يقيم رمزه على بيئة renovate مقصورة على master لا بين أسرار المستودع: فهو يملك صلاحية الكتابة في سيور العمل، أي أنه قادر على إعادة كتابة الملفات نفسها التي تحمل مفتاح SSH للنشر. وهو يفتح طلبات سحب، فيعود خَرْجه من باب ci.yml كأي تغيير آخر. وثمّة عُرف يفرضه يطال كل سيور العمل هنا — فإجراء خارجي مثبَّت على SHA مجرّد يكون خفيًّا عنه، ولذلك يحمل كل تثبيت نسخته في تعليق لاحق # vX.Y.Z، وحذف أحدها يوقف مراقبة ذلك الإجراء دون أن يقول شيئًا.

ما يفعله البناء فعلًا

flowchart TD
  loader["محمّلات المحتوى"] --> client["عميل Strapi المُولَّد"]
  client --> strapi["Strapi"]
  loader --> store["مخزن المحتوى"]
  store --> data["طبقة بيانات الميزة"]
  page["صفحة Astro"] --> routes["تحديد اللغة والمسار"]
  page --> i18n["الترجمات"]
  page --> data
  page --> html["HTML مُعَدّ مسبقًا"]
  html --> islands["جزر مُفعَّلة"]

تصيير الصفحة في معظمه حسمٌ للمراجع. المسار يحدّد اللغة، واللغة تختار حزمة ترجمة، ووحدات الميزات تقرأ من مخزن محتوى Astro ما تحتاجه الصفحة من محتوى — وقد جُلب ذلك بالفعل، مرة واحدة لكل بناء، في مرحلة المزامنة الموصوفة أعلاه. وحيث تكون لميزة تحت src/features/ بيانات لتقرأها، تُبقي هذا الفصل صريحًا، وأسماء الملفات هي الأدوار: repository.ts هو طبقة الجلب التي يستدعيها مُحمِّل محتوى، ومُسنَد الصحّة يسافر مع المجموعة في content.config.ts، وservice.ts يقرأ النتيجة مجدَّدًا عبر getCollection أو getEntry، بحيث لا تتحدّث صفحة إلى Strapi بنفسها أبدًا. أما contact فيتخطّى الطبقة كليًا، وsearch يُبقي service.ts لكنه لا يحتاج repository خاصًّا به، لأنه يقرأ عبر أربع ميزات أخرى.

وهذه الصفحة تنتمي إلى النصفين معًا. يأتي شكل الشجرة من أثر JSON مولَّد، بينما تأتي ملاحظات المسارات وهذه النظرة العامة من نوع المحتوى المحلي File Annotation في Strapi. ويستعلم رسم المساهمات تحتها من Strapi أيضًا، لكن ليس مباشرة في وقت التصيير: فـ about/contributions/repository.ts — وهو الـ repository الوحيد في قاعدة الكود الذي يستخدم fetch مجرّدًا بدل العميل المولَّد، لأن نقطة نهايته مسار Strapi مخصَّص لا نوع محتوى — يعمل داخل مُحمِّل أثناء مرحلة المزامنة، ويقرأ service.ts الخاص بالرسم النتيجة مجدَّدًا من مخزن المحتوى. وهذا المُحمِّل متسامح: نقطة نهاية يتعذّر الوصول إليها أو حمولة تفشل في مخطَّطها لا تكتب أي مُدخَل على الإطلاق، فيختفي الرسم بدل أن يُفشِل البناء. وما يخرج من الطرف الآخر HTML في الحالتين.

التفاعلية اختيار صريح. القاعدة التي تتبعها قاعدة الكود هي أن مكوّنات Astro تملك التخطيط وكل ما يمكن حسمه في وقت البناء، وأن ملفات React بامتداد .tsx لا تظهر إلا حيث تكون الواجهة تفاعلية حقًا — لوحة البحث، والقوائم، والكاروسيلات. وكل ما عدا ذلك يُسلَّم HTML بلا أي JavaScript ملحق. أمّا السلوك الذي يحتاج شجرة DOM دون أي إطار عمل — الكاروسيلات، ومرشِّحا الصفحتين، وحقل الفيزياء تحت المهارات — فهو Controller: صنف يملك عنصر جذر واحدًا، ويسجّل مستمعيه عبر مساعد يتذكّر كيف يفكّها، ويركّبه مدير دورة الحياة، فيفكّه انتقالُ العرض ويعيد بناءه بدل أن يتركه يسرّب. لوحة البحث تُماه (hydrate) بـ client:idle ولا تجلب فهرسها حتى تفتحها.

كان التوقيت في السابق الجزء الوحيد الذي ليس حسمًا خالصًا: كان getSearchIndex يحفظ مؤقتًا وعدًا (promise) لكل لغة كي لا يعيد كل تصيير من أربعة — واحد لكل لغة — جلب كل مجموعة من Strapi. أما الآن وقد بات البحث يقرأ من مخزن المحتوى بدل أن يجلب بنفسه، فلم يعد هناك ما يُعاد جلبه — فمرحلة المزامنة عملت بالفعل مرة واحدة لكل اللغات الأربع قبل أن تُصيَّر أي صفحة — فحُذف الحفظ المؤقت لكل لغة كليًا.

أربع طبقات تحت src/

تنقسم apps/web/src/ إلى أربع طبقات، والاتجاه بينها هو المقصود. فـ core/ هي الآلية التي لا يصيّرها شيء: مدير دورة الحياة، والفئة الأساس Controller، وعميل Strapi مع repository المُصفِّح خاصّته، ومُحمِّلات مجموعات المحتوى وقارئاتها. وdesign-system/ عرضٌ بلا أي معرفة بالمجال — primitives/ للذرّات ووصفات تنسيقها، وpatterns/ للقطع المركّبة التي لا تزال لا تعرف ما المشروع. وshell/ هي الكسوة التي ترتديها كل صفحة: الترويسة والتذييل وتخطيط الأقسام ومؤشّر التحميل وتحسين محرّكات البحث والتحليلات. وfeatures/ هي كل ما يعرف المجال.

ويجوز لميزة أن تمتدّ إلى أي من الطبقات الثلاث الأخرى؛ ولا يجوز لأيٍّ منها أن تمتدّ راجعةً إلى ميزة، ولا تبلغ ميزةٌ ميزةً أخرى إلا عبر service.ts الخاص بها. وداخل الميزة، أسماء الملفات هي الأدوار، وهذا ما يجعل الموضع يكفّ عن كونه سؤالًا: فالوحدة التي تجلب من Strapi لها موضع واحد بالضبط تعيش فيه، والملف الذي يصعب تسميته يفعل عادةً شيئين. وقد كانت الشجرة تحمل مجلّدات components/ وutils/ وconstants/ وcommon/ لا تستطيع الإجابة عن أيٍّ من السؤالين. ولم يعد فيها شيء منها.

أربع لغات، مجلّد صفحات واحد

التوجيه المُترجَم مقسوم عمدًا. يُنتج البيان في src/i18n/route-manifest.ts أنماط URL المترجمة لـ Paraglide ويغذّي تكامل Astro المحلي localized-static-routes. أثناء astro:config:setup يمشي التكامل في src/pages/، ويتحقق من البيان، ثم يستدعي injectRoute لكل لغة غير افتراضية. تبقى الإنجليزية بلا بادئة؛ وتحصل اللغات الأخرى على /{locale} وعلى slug مترجَم حين يكون مصرّحًا به. لهذا توجد هذه الصفحة على /about/this-website وعلى /de/ueber-mich/diese-website معًا.

يتولى Paraglide عكس عناوين URL وتجميع الرسائل. تفوّض المساعدات المنمّطة في src/i18n/routes.ts إليه localeFromUrl وlocalizedHref وalternateHrefs. تقرأ ملفات .astro، ووحدات .ts التي تصل إليها هي فقط، الرسائل عبر useMessages(locale) — وهو واجهة وسيطة لكل لغة فوق الحزمة المولَّدة في src/i18n/messages.ts، مفهرَسة بمفاتيح الكتالوج نفسها. أما ما يمكن لـ Astro شحنه إلى المتصفح — جزر React، والوحدات التي لا يصل إليها إلا سكربت Astro، وكل ما تستورده هذه الوحدات — فلا يزال يستورد دوال الرسائل المولَّدة التي يحتاجها بعينها ويمرّر اللغة صراحةً، حفاظًا على تشذيب الكتالوج؛ وتفشل مجموعة اختبارات مخصّصة البناء إذا استوردت وحدة قابلة للوصول من المتصفح الحزمة الأساسية بدل ذلك. لا توجد middleware أو provider أو حالة بناء عامة أو حمولة فهرس ترجمات للعميل.

هنا فخّ يستحق أن يُقال بصراحة، لأنه يفشل بصمت. localizedHref تضع البادئة دائمًا للغات غير الافتراضية، سواء وُجد لها مدخل في البيان أو لا — فالمسار غير المُخطَّط يسقط مباشرة إلى /{locale}{route}. المدخل في الخريطة ليس ما يجلب لك البادئة؛ بل هو ما يجلب لك slug مترجَمًا. لذا فإن localizedHref('/about', { locale: 'ar' }) بدون مدخل تعيد /ar/about: مسبوقة بشكل صحيح، ورابط يعمل تمامًا، مع slug إنجليزي. نسيان مدخل لا ينتج رابطًا مكسورًا، بل ينتج رابطًا خاطئًا بلطف، وهذا أسهل بكثير أن يمرّ في المراجعة.

الـ 532 ملّي ثانية من الضوء

كان الوضع الداكن يعتمد على صنف (class) — @custom-variant dark يطابق صنف .dark كان JavaScript يضيفه عند DOMContentLoaded. ولأن CSS لم يحمل أي احتياط prefers-color-scheme، كان الزائر الذي نظامه مضبوط على الداكن يُقدَّم له اللوح الفاتح ويراه، لمدة 532 ملّي ثانية مقيسة، قبل أن يحلّ الصنف.

كان الإصلاح حذفَ كود. تعرّف السِّمة المشتركة في packages/tailwind-config/theme.css الآن اللوح الفاتح على :root ويتجاوزه داخل استعلام وسائط prefers-color-scheme: dark — وهو بالضبط ما تُحَل إليه صيغة dark: المدمجة في Tailwind v4، فلم يبق أي تجاوز @custom-variant على الإطلاق وتظلّ كل أداة dark: تعمل دون تغيير. استعلام الوسائط يُحَل قبل أول رسم، فلا يمكن أن يحدث الوميض. كما يعلن :root color-scheme: light dark، وهذا يضع أشرطة التمرير الأصلية وعناصر النماذج ولون اللوحة قبل الرسم على إعداد النظام أيضًا؛ أما النسخة القائمة على الصنف فكانت تترك كل ذلك عالقًا على الفاتح.

قرار قريب يقع بعد أسطر قليلة أعلاه، على هيئة تعليق في المكان الذي كان يشغله رمز تصميم. حركة دخول باسم --animate-fade-in كانت تستخدم نمط التعبئة both، فكان كل غلاف تلمسه يبقى عند opacity: 0 حتى تبدأ حركته — بما في ذلك عنوان الواجهة وعمودَي الملف الشخصي كليهما. وهذا جعل عنصر LCP غير مستقر: كان يحلّ على شعار الشريط، أو على فقرة، أو على صورة الملف الشخصي حسب التشغيل، وكلّف نحو 100 ملّي ثانية من LCP. الرمز اختفى والتعليق يشرح السبب، حتى لا يعيده أحد. المحتوى يجب أن يكون حاضرًا وحسب.

استيراد واحد للأيقونات

كل مكوّن يستورد Icon من $/design-system/primitives/Icon.astro، ولا يستورده أبدًا من astro-icon/components مباشرة، وهذا الغلاف يفعل شيئًا واحدًا بالضبط: يفرض is:inline.

بدونه، يزيل astro-icon تكرار الرسوم المُجمَّعة (sprites) بأن يمنح أول تصيير لاسم أيقونة تعريف <symbol> ويمنح كل تصيير لاحق مجرّد <use href="#...">. ترتيب المستند هو ما يحدّد مَن الأول — وفي هذا التطبيق قد يكون ذلك وسمًا داخل فتحة جزيرة astro، وهو ما يسلّمه Astro داخل <template data-astro-template> خامد حتى الإماهة. فلا يدخل الرمز إلى شجرة DOM الحيّة أبدًا، وكل <use> لذلك الاسم لا يصيّر شيئًا. وعندما حدث ذلك، أطاح بأيقونات السهم-يمينًا الثماني والعشرين كلها، بالإضافة إلى أيقونتَي GitHub والرابط في التذييل، على شاشة مكتب. والاستخدام المختلط لا ينجّي الوضع أيضًا، لأن التصييرات المضمّنة تُقدِّم عدّاد astro-icon لكل اسم مع ذلك، فالتضمين في كل مكان هو السياسة المتّسقة الوحيدة. وثمّة اختبار مصاحب، tests/repo-guards.test.ts، يتحقّق من أن كل اسم أيقونة مستخدَم موجود فعلًا في مجموعة الأيقونات المثبَّتة.

نقطة النهاية التي تتوارى عن الموجِّه

البحث ملف JSON ثابت لكل لغة. تجمّع src/features/search/ مصفوفة SearchDoc[] في وقت البناء من المشاريع والمقالات ومداخل الخط الزمني والوسوم ومستندات صفحات مكتوبة يدويًا، وتُصدِر نقطة النهاية في src/pages/search-index/[locale].json.ts المسار /search-index/{locale}.json.

امتداد الملف هذا حامل للبنية. مُحمِّل المسارات الثابتة لا يمشي إلا في ملفات .astro، لذا فنقطة نهاية .ts غير مرئية له: التكامل لا يحقن أبدًا نسخًا مسبوقة باللغة، وتبقى مسارات اللغات التي تنتجها نقطة النهاية هي النصوص الحرفية المكتوبة في الملف. ولو جُعلت مسارًا .astro لأعطينا التكامل نقطة نهاية لترجمتها، وهذا ليس ما يريده فهرس JSON لكل لغة.

شجرة هذه الصفحة نفسها

الشجرة على الجانب لا تُقرأ من القرص عند تحميلك الصفحة — لا يوجد أي شيء عند وقت الطلب. يستدعي apps/web/scripts/generate-repo-tree.mjs الأمر git ls-files ويطوي النتيجة في JSON متداخل في src/.generated/repo-tree.json. هذا الملف مُدرَج في النسخ، لا لأن محتواه يقتضي ذلك: فهو دالة خالصة لقائمة الملفات المتتبَّعة، أي أن شجرة العمل تحدّده بالكامل أصلًا. بل أُدرِج كي يُبصِّمه Turborepo — فالشجرة تأتي من git ls-files عند جذر المستودع، بينما يبصّم turbo مدخلاته لكل حزمة على حدة، فكان الملف الذي يُضاف تحت apps/admin/ يغيّر هذه الصفحة دون أن يغيّر بصمة بناء الويب، فتقدّم إصابةُ الذاكرة المؤقتة الشجرةَ القديمة. وتعمل خطوة gen:tree قبل dev وbuild وtest وtypecheck، فيكون محدَّثًا دائمًا وقت أن يقرأه أي شيء، وتُسقِط CI طلبَ الدمج متى انحرفت النسخة المُدرَجة.

تفويض المهمة إلى git بدل المشي في نظام الملفات يعني أن .gitignore وحده يقرّر ما هو عام، بلا تطبيق ثانٍ لمطابقة الاستثناءات يجب إبقاؤه متوافقًا. والمسوّدات غير المتتبَّعة تحت draft/ وtemp/ لا تظهر ببساطة أبدًا. ويمرّ الترتيب عبر Intl.Collator('en') ثابت حتى لا يتغيّر الناتج من جهاز إلى آخر، ولا يحمل الأثر سوى عدد الملفات وعدد المجلّدات والأبناء المتداخلة. وقد كانت نسخة أقدم تختم فيه أيضًا معرّف SHA الخاص بـ HEAD، وهو بالضبط الحقل الذي لا يمكن لأثر مولَّد ثم مُدرَج في النسخ أن يحمله: فكتابة الملف تقدّم HEAD، أي أن الخَتم يصبح قديمًا في اللحظة نفسها التي يحلّ فيها. وحذف الخَتم وإخراج الأثر من التتبّع كانا الإصلاح ذاته؛ وقد عاد الأثر منذ ذلك الحين إلى التتبّع، أما الخَتم فلا، لأن قائمة الملفات تستقرّ حيث لا يستطيع خَتم HEAD أن يستقرّ.

الملاحظات المرتبطة بمسارات معيّنة سجلات محلية من نوع File Annotation في Strapi. ويُدقَّق كل مسار عادي مقابل الشجرة المولَّدة بعد جلب السجلات؛ ويُتخطّى المسار القديم مع تحذير في البناء كي لا يمنع خطأ واحد في CMS عمليات نشر أخرى. أما السجل المحجوز $$ROOT_ANNOTATION$$ فيزوّد هذه النظرة العامة بدل أن يشير إلى عقدة في الشجرة. وهو مطلوب بالإنجليزية، وتعود اللغات الأخرى إليها عند غياب ترجمتها، ولا تُستبدل علامتا الرسم أعلاه إلا بعد حسم markdown القادم من CMS.

هذا الموقع وما سبقه

خمس نسخ في عامين ونصف. كل واحدة حلّت مشكلة سابقتها وجاءت بمشكلتها — غالبًا بالقفز إلى إطار عمل قبل أن تكبر المشكلة بما يكفي لتبريره.

  1. v1فبراير 2024 – أغسطس 2025

    4 commits

    صفحات مكتوبة باليد

    طريقة العرض
    ملفات ثابتة
    المحتوى
    مكتوب داخل الوسم مباشرة
    الخادم
    لا شيء
    المستودعات
    Hadi-Hijazi · HadiHz88.github.io

    مستودعان، وأربعة commits، وبلا أي خطوة بناء: HTML وملف أنماط مدفوعان إلى GitHub Pages. لا شيء يُترجم، ولا شيء يُنشر، ولا شيء يُدفع ثمنه — وما زالت أسرع نسخة عرفها هذا الموقع. وانتهت لسبب بديهي: المحتوى والتصميم في الملف نفسه، فإضافة مشروع تعني نسخ كتلة من الوسم وتذكّر كل موضع آخر يجب تعديله.

  2. v2مارس – أبريل 2025

    50 commit

    Laravel، وواجهة لم أكتبها

    طريقة العرض
    عرض من جهة العميل
    المحتوى
    قاعدة بيانات خلف Laravel
    الخادم
    PHP و Node
    المستودعات
    My-Platform-BackEnd · My-Platform-FrontEnd

    أردت خلفية حقيقية — قاعدة بيانات، ولوحة إدارة، ومصادقة — وأعطاني Laravel الثلاثة في بعد ظهيرة واحدة. تعلّمه كان معظم الغاية، وهذا الجزء نجح. أما الواجهة فقد وُلِّدت ولم تُكتب، فلم تصر لي يومًا. وبعد ثلاثة أسابيع اتضح شكل المشكلة: تطبيقان، ونشران، وخادم يعمل على مدار الساعة ليقدّم صفحة تتغيّر مرتين في الشهر.

  3. v3يوليو – سبتمبر 2025

    144 commit

    البنية نفسها، بلغة واحدة

    طريقة العرض
    عرض من جهة العميل
    المحتوى
    قاعدة بيانات خلف NestJS
    الخادم
    Node
    المستودعات
    my-website-backend · My-Website-Client

    إعادة كتابة الواجهة البرمجية بـ NestJS أعادت الشيفرة إليّ: لغة واحدة على الطرفين، وأنواع مشتركة من البداية إلى النهاية، وتجربة يومية أفضل بكثير. لكن ما لم يتغيّر هو البنية. بقيت واجهة CRUD مكتوبة يدويًا لبضع عشرات من السجلات، وبقي تطبيق صفحة واحدة يرسم شاشة فارغة قبل أن يرسم المحتوى، وبقي شيئان يجب إبقاؤهما منشورَين لموقع لا يسجّل أحد الدخول إليه.

  4. v4نوفمبر 2025 – فبراير 2026

    92 commit

    ثابت، والمحتوى داخل المستودع

    طريقة العرض
    معدّ مسبقًا عند البناء
    المحتوى
    ملفات JSON داخل المستودع
    الخادم
    لا شيء
    المستودعات
    HadiHz-Portfolio

    حذف الخلفية حلّ مشكلة الأداء دفعة واحدة. كان Next.js يُعدّ كل صفحة مسبقًا، والمحتوى يجلس في ملفات JSON بجانب الشيفرة، ولم يبقَ وقت تشغيل يمكن أن يبطئ أو يسقط. لكن الكلفة انتقلت ولم تختفِ: كل خطأ مطبعي صار commit، وJSON سطح بائس للكتابة، ولغة ثانية كانت ستعني صيانة ملفات متوازية يدويًا.

  5. v5أبريل 2026 – حتى الآن

    857 commit

    Astro و Strapi

    طريقة العرض
    معدّ مسبقًا، مع جزر
    المحتوى
    Strapi
    الخادم
    لا شيء عند القراءة
    المستودعات
    hadihz.me

    هذه النسخة تحتفظ بنتيجة سابقتها وتعيد إمكانية الكتابة. يحتفظ Strapi بالمحتوى، ويُعدّ Astro كل صفحة مسبقًا، ولا يُرسَل أي JavaScript ما لم يطلبه مكوّن — شجرة الملفات أعلاه والمخطط أدناه جزيرتان، وكل ما حولهما HTML خالص. والمقايضة الباقية ظاهرة لا مخفية: النشر يتطلب إعادة بناء، ولهذا يوجد خط نشر.

الـ commits شهريًا

النسخ الخمس نفسها، مقيسة. خط لكل نسخة، متقطّع منذ أن توقفت عن تشغيل الموقع.

1,147 commit في هذه المستودعات

البيانات حتى 30‏/9‏/2026