هذا الموقع
كيف يُبنى هذا الموقع، وما الموجود في المستودع الذي يُنتجه.
المستودع
هذا الموقع عبارة عن معرض أعمال ثابت بأربع لغات مبني بـ 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.
.github/workflows
سيور العمل والإجراءات المركّبة هنا مقسومة بحيث يكون لكل واحد مُشغِّل واحد وشكل
مهمة واحد. يعمل ci.yml على طلبات السحب ويحتوي مهمة واحدة، Checks: فحص
حداثة شجرة الملفات، ثم format:check، ثم turbo run lint typecheck test --continue، ثم بناء الموقع. وهي الفحص الوحيد الذي تطلبه حماية الفرع. كانت
ثلاث مهام خلف بوّابة تجميع تُسمّى CI complete، وكانت كل واحدة منها تنفّذ
تثبيتًا كاملًا لمساحة العمل — مهمة الموقع تثبّت Strapi بأكمله، ومهمة لوحة
الإدارة تثبّت Astro بأكمله — فكان التثبيت يُدفَع ثلاث مرات لتشغيل prettier
وtsc وVitest مرة واحدة. ولا يزال Turbo يشغّل المهام على التوازي داخل المهمة
الواحدة، و--continue يجعل كل فشل يظهر في تشغيل واحد.
ولا يوجد مُشغِّل push عن قصد. فـpull_request لا يفحص الفرع بل يفحص
refs/pull/N/merge — أي الرأس مدموجًا في القاعدة — وما لم تتحرّك القاعدة، فإن
الشجرة التي تحلّ في master هي نفسها التي فُحصت، وإعادة فحصها لا تُثبت شيئًا.
وهذه الحجّة لا تصحّ إلا إذا تعذّر تحرّك القاعدة من تحت طلب سحب قديم، وذلك ضبط
في حماية الفرع لا شيء في YAML: اطلب أن تكون الفروع محدَّثة، أو استخدم طابور
دمج.
يعمل deploy.yml على الدفع إلى master ولا يحمل أي فحوص. يحدّد أهداف النشر
ثم يستدعي cd-cms.yml وcd-web.yml بهذا الترتيب، لأن الصفحات المُصيَّرة
مسبقًا تخبز استجابات Strapi في الخَرج الساكن. ولا يُرشَّح بالمسارات إلا نظام
إدارة المحتوى: فهو رفع بحجم ~1 غيغابايت وإعادة تشغيل لـStrapi، أما نشر الموقع
فبناء ومزامنة إلى S3. وقد كان ترشيح نشر الموقع خطأً في الحقيقة — إذ ينشر
/about/this-website شجرةً مبنيّة من git ls-files من جذر المستودع، فأي
إيداع يلمس docs/ وحدها يغيّر صفحة منشورة — وكان يسلّم شجرة قديمة في صمت.
ويبقى شرط واحد لانتشار التخطّي، على مهمة الموقع: فمهمة cms تتخطّى بحقّ،
و!cancelled() هو ما يسمح لمهمة أن ترى سابقةً متخطَّاة بدل أن تُتخطّى معها،
بثمنِ إعادة ذكر البوّابات التي يطرحها.
وWEB_ENV سرٌّ على مستوى المستودع، بينما تقيم أسرار النشر الأربعة على بيئة
Production. فمهمة الفحص تحتاجه، لأن astro check يشغّل مزامنة محتوى عبر كل
مُحمِّل مقابل Strapi حيّ، والسبيل الوحيد لقراءة سرّ بيئة هو إعلان تلك البيئة —
وهو ما كان سيسجّل نشرًا على Production مع كل طلب سحب ويسلّم محتواه لأي فرع
داخل المستودع. ولم تعد الاختبارات تحتاج أي أسرار: فـastro.config.mjs لم يبقَ
يرمي خطأً بغياب CDN_URL. أما طلبات السحب من fork فلا تستطيع التحقّق من
الأنواع، لأن GitHub لا يرسل إليها أسرارًا.
ويصل WEB_ENV إلى مهمة الفحص وإلى نشر الموقع بالطريقة نفسها — يُكتب في
apps/web/.env ويُصدَّر أيضًا — وهذا التماثل حاملٌ للمعنى لا مجرّد ترتيب.
فـTurborepo يحسب بصمة الملف عبر inputs وبصمة القيم المصدَّرة عبر env،
فالمهمة التي تكتفي بأحدهما تحسب بصمة بناء مختلفة وتعيد بناء ما بناه الطرف
الآخر أصلًا. والقيام بالاثنين معًا هو ما يتيح للنشر أن يستعيد الناتج الذي
أنتجته طلبات السحب. وتُوقَّع نواتج الذاكرة المؤقتة البعيدة
بـTURBO_REMOTE_CACHE_SIGNATURE_KEY، وإن لم يُضبط فإن turbo ينبّه مرة واحدة
ويبني كل شيء من جديد.
أربعة قرارات تحصين لا يُستحسن نقضها. الإجراءات الخارجية مثبَّتة على بصمات
إيداع بينما تبقى إجراءات الطرف الأول على وسوم رئيسية:
فـburnett01/rsync-deployments وappleboy/ssh-action يتلقّى كلٌّ منهما
SERVER_SSH_KEY، وهو جذرٌ على الخادم، والوسم المتحرّك أكثر ممّا يصحّ التعويل
به على مشرف واحد هناك. ويقارن نشر نظام إدارة المحتوى إصدار Node الرئيسي على
الخادم بذلك الذي على العامل ويخرج قبل cp -al إن اختلفا، لأن الحزمة تشحن
وحدات أصلية مبنيّة على العامل. وفشلُ فحص السلامة يتراجع إلى previous ويعيد
الفحص، فيخدم الإنتاج آخر إصدار معروف بسلامته وإن ظلّت المهمة حمراء. ورفع
الموقع ثلاث مرّات — الأصول الثابتة، ثم الصفحات مع --delete، ثم تقليم — فلا
تبقى نافذة تشير فيها صفحة حيّة إلى أصول غائبة أو محذوفة.
وrenovate.yml هو الخامس، والوحيد الذي يكتب في المستودع بدل أن ينشر منه. يعمل
مستضافًا ذاتيًا على مؤقّت يوم الاثنين — فـRenovate لا يتحرّك إلا أثناء تشغيل
المهمة، فالمؤقّت إذن هو الجدول كلّه — ورمزه يقيم على بيئة renovate لا بين
أسرار المستودع، لأن صلاحية الكتابة في سيور العمل تعني أنه قادر على إعادة كتابة
الملفات نفسها التي تحمل SERVER_SSH_KEY.
ومعظم الشرح النصّي يقيم في docs/CI-CD.md، لا كلّه: فـrenovate.yml يحتفظ
بحُججه في موضعه، في عشرين سطرًا من التعليق، لأن ما يبرّر سرّ بيئته ووضع
التجربة الجافّة الافتراضي يخصّ ذلك الملف لا خطّ الأنابيب. وثمّة تعليق واحد
حامل لا شارح — فكل تثبيت SHA لإجراء خارجي ينتهي بـ# vX.Y.Z، وRenovate يعطّل
أي SHA مجرّد لا يستطيع ردّه إلى وسم، فحذف أحدها يوقف مراقبة ذلك الإجراء ولا
يقول شيئًا.
AGENTS.md
كان هذا الملف 528 سطرًا. صار 69 سطرًا، والمنطق وراء هذا التقليم أنفع من الفرق نفسه.
كانت النسخة القديمة تخزّن ما يسجّله المستودع أصلًا — إصدارات الاعتماديات وجرودًا لكل أمر — فكانت تبلى بمجرّد بقائها ساكنة، وكانت القيود التي تهمّ فعلًا مدفونة تحت ذلك الحشو. وما بقي لا يفعل إلّا التوجيه: ما هو هذا المستودع، وأوامر البوّابات الستّة، والقواعد السارية في كل مكان، وجدول «أين تقرأ بعد ذلك».
نزل التفصيل ولم يختفِ. فـ apps/web/AGENTS.md (121 سطرًا) و
apps/admin/AGENTS.md (103) يحملان القيود التي لا يعترف بها أي ملفّ مصدر —
خادم التطوير الذي ينفصل ويردّ كعبًا من 70 بايتًا بدل أثر الاستدعاء، و
git stash الذي يُسقط جداول Strapi وهو يراقب — كلٌّ مضغوط في سطر إلى ثلاثة:
القيد وسببه. وانكمش CLAUDE.md إلى ثمانية أسطر، ولم يُبقِ إلّا الفخّ الوحيد
الذي يعضّ قبل قراءة هذا الملف.
وصار الباقي خمس مهارات تحت .claude/skills/: client-controllers و
feature-modules و design-system و output-neutral-refactor (الذي يحمل
سكربتات بوّاباته إلى جانب النصّ) و cms-annotations. وتُحمَّل المهارة عند
الحاجة — إذ يذكر وصفها في الترويسة المواضع التي تنطبق فيها — فيبقى الجزء
المحمَّل دائمًا من سياق الوكيل صغيرًا بينما يظلّ العمق على بعد قراءة واحدة.
وتنصّ قاعدة واحدة على المبدأ الذي يرمّزه هذا التقسيم كلّه: اقرأ الإصدارات من
package.json لكل حزمة، لا من النثر. فما يُكتب هنا هو ما لا يعترف به أي ملفّ،
وما يذكره الملفّ أصلًا يُترك له.
apps/admin/src/api
خمسة عشر نوع محتوى في شكل Strapi المعياري ذي الأدلّة الأربعة، كلّها مؤقلمة عدا
نوعَي الإرسال، اللذين يخزّنان ما كتبه زائر ولا ترجمة لهما. و about و hero و
profile أنواع مفردة، والبقية مجموعات، وهذا الانقسام ينتقل إلى عميل الوِب
المولَّد كصنفين مختلفين — SingleTypeAPI لا يعرض إلّا find و update، و
CollectionAPI بالطقم الكامل — فيصير طلب دالّة مجموعة على نوع مفرد خطأً في
الأنواع لا مفاجأة وقت التشغيل.
وثلاثة من الأدلّة الثمانية عشر هنا لا تحوي أيّ نوع محتوى إطلاقًا، وهذا شكل
النقطة الطرفية المخصّصة بالكامل في Strapi: موجّه ومتحكّم وأحيانًا خدمة، وبلا
content-types/. فـ contribution و contribution-repository يخدمان رسم
المساهمات والخطّ الزمني للمستودع في هذه الصفحة من واجهة GitHub بلغة GraphQL. و
github-auth هو ساق الدخول عبر GitHub، والموضع الوحيد الذي يستبدل فيه هذا
النظام تدفّق إضافة بدل أن يوسّعه — إذ ينتهي /connect/* الخاص بـ Strapi بكتابة
access_token الخاصّ بالمزوّد في سلسلة استعلام إعادة توجيه، فيستقرّ في سجلّ
المتصفّح، ولا خطّاف يغيّر ذلك. وما يخصّنا هو كعكة الحالة وتبادل الرمز بالكود
وكعكة الجلسة فقط.
وتسعة وثلاثون من اثنين وخمسين ملفّ متحكّم وموجّه وخدمة هي استدعاءات
factories.createCoreX(...) من سبعة أسطر: فتطبيق الوِب يقرأ عبر واجهة REST
برمز ولا يحتاج نقاطًا طرفية مفصّلة. والثلاثة عشر الباقية تخصّ الواجهات المخصّصة
الخمس — قارئَي المساهمات، وساقَي الدخول، والكتابتين العامّتين.
وهاتان الكتابتان هما سبب وجود testimonial-submission نوعَ محتوى مستقلًّا بدل
أن يكون مسارًا على testimonial. فكلا نوعَي الإرسال يأخذ موجّهًا مخصّصًا بدل
createCoreRouter، لأن طقم CRUD الافتراضي كان سيضع create و update و
delete على بُعد مربّع تأشير واحد من العموم. وفصل كتابة الشهادات تمامًا يمضي
أبعد: فمجموعة testimonial المنشورة التي يقرأها الموقع تبقى صلاحياتها العامّة
للقراءة فقط، ولا يجلس بجانبها create عامّ ينتظر تشغيله سهوًا. و
POST /api/testimonials/submit بـ auth: false — فالصفحة الثابتة لا تملك سرًّا
تقدّمه — فيقوم مقام المصادقة مصيدةُ عسل تردّ 200 ولا تخزّن شيئًا، ومستويان
لتحديد المعدّل مبنيّان على عنوان IP.
ودورات الحياة تحوي الشيفرة المثيرة. فـ tag يطبّع حقل aliases بصيغة JSON عند
الكتابة، ويحوّل AliasError القادم من وحدة بلا إطار إلى ValidationError الخاص
بـ Strapi كي تعلّقه لوحة الإدارة على الحقل الصحيح؛ والأسماء البديلة هي ما يجعل
مرادفات الوسوم قابلة للبحث. وستّة أنواع تصدّر createTagMirror(...)، الذي ينسخ
قائمة الوسوم من الوثيقة الإنجليزية إلى كل لغة أخرى، لأن سجلّ العلاقة يخزّن
معرّف سجلّ لا معرّف وثيقة، وكل لغة سجلّ منفصل — فربط وسم على المشروع
الإنجليزي يترك السجلّ الفرنسي بلا وسوم. ولا manyToMany ولا
pluginOptions.i18n.localized: false يغيّران ذلك؛ وقد جُرّبا كلاهما.
ولا شيء هنا يكتب إلى النظام عند الإقلاع — فلا بذور إطلاقًا. والذي يقرأ هو
scripts/cms-snapshot/pull.ts، يكتب كل نوع api:: إلى .cms-snapshot/
المتجاهَل في git، واللغات الأربع جنبًا إلى جنب كي تُرى الترجمة المنحرفة بالعين؛
الوثائق المنشورة فقط، و contact-submission متروك. وهو مشتقّ للقراءة فقط. أمّا
نصوص استمارات لوحة الإدارة ومتنا بريدَي المصادقة فتقيم في core_store لا في أيّ
نوع محتوى، وهذا ما يجعل pnpm --filter admin cms:export النسخة الوحيدة منها
والنسخة الاحتياطية الوحيدة للمحتوى. ولا بدّ من تشغيله فعلًا: فقد سبق أن فقد هذا
المستودع سجلّات File Annotation مرّة.
apps/web/astro.config.mjs
أهمّ ما في هذا الملف أثرًا هو الغائب عنه: لا يوجد adapter. وهذا ما يجعل الموقع
ثابتًا تمامًا — HTML مولَّد مسبقًا، ولا خادم عند وقت الطلب، وكل قراءة من Strapi
محسومة أثناء البناء.
معالجة متغيّرات البيئة منقسمة إلى قسمين، عن قصد. تُسحَب CDN_URL و
IMAGE_DOMAINS عبر loadEnv الخاص بـ Vite في وقت تقييم الإعداد، لأنهما تبنيان
قائمة السماح image.domains قبل أن يعمل تحقّق البيئة الخاص بـ Astro نفسه.
وكلتاهما اختيارية وتراكمية: كل واحدة تضيف مضيفات، وقد يكون المدخل مضيفًا مجرّدًا
أو عنوانًا كاملًا، ولا واحدة منهما لازمة. وكان الإعداد يرمي خطأً بغياب
CDN_URL، فكان ذلك يفرض على كل تشغيل لـVitest أن يملك بيانات اعتماد الإنتاج —
إذ يبني vitest.config.ts إعداده عبر getViteConfig — وهذا بدوره كان يفرض على
مهمة pull_request أن تعلن بيئة Production لمجرّد قراءتها. وكل ما عدا ذلك
يمرّ عبر env.schema: STRAPI_BASE_URL و SITE_URL
كقيمتين عامّتين على الخادم مع قيم افتراضية وتحقّق من صيغة العنوان، و
STRAPI_API_TOKEN بـ access: 'secret' — في سياق الخادم فقط، فلا يمكن أن يرشح
إلى حزمة العميل ولو بالخطأ.
سطر واحد يقرّر أين يقيم مخزن طبقة المحتوى، ويستحقّ أن يُعرَف سبب خروجه عن
الافتراضي. يحسم Astro ذلك المخزن إلى .astro/ تحت astro dev، وإلى cacheDir
في كل أمر آخر. وVitest يمرّ عبر getViteConfig، وهو فرع التطوير، فيقرأ
.astro/data-store.json، بينما يكتب astro sync و astro check نسخة
cacheDir — ملفّان مختلفان تحت node_modules/.astro الافتراضي. وعندها كانت
الاختبارات المعتمدة على المحتوى تنجح فقط على جهاز صادف أن ترك فيه astro dev
مخزنًا، ولا تنجح أبدًا على سحب CI نظيف. وضبط cacheDir: '.astro' يجمع الثلاثة
على ملفّ واحد.
أربعة تكاملات، بهذا الترتيب: sondaPost() (تكامل ثانٍ داخل المستودع يولّد
تقريرًا عن الحزمة عند astro:build:done)، ثم
localizedStaticRoutes(...)، ثم react()، ثم icon(). يستهلك تكامل
المسارات الثابتة translatedRoutes من src/i18n/route-manifest.ts، ويولّد
الملف نفسه urlPatterns الخاصة بـ Paraglide لمكوّن Vite، كي تبقى الصفحات
المحقونة وروابط اللغات وعكس الأقلمة متوافقة. ويظهر /about/this-website
بصيغة /حول/هذا-الموقع و /a-propos/ce-site و
/ueber-mich/diese-website.
والباقي ضبط دقيق: prefetch مفعّل لكل رابط مع hover افتراضيًا، وتزامن البناء
عند 6، وشريط أدوات التطوير مغلق، وخرائط مصدر Vite مفعّلة، وثلاثة خطوط Google
مسجّلة عبر مزوّد الخطوط في Astro — Quicksand للنصّ اللاتيني، وRubik للعربية، و
Pixelify Sans لخط العرض البكسلي — كلٌّ منها معروض كمتغيّر CSS يحوّله السِّمة
المشتركة إلى أداة font-*.
apps/web/integrations/localized-static-routes
تكامل Astro مخصَّص وثابت فقط، مكتوب لهذا المستودع. عند
astro:config:setup يمشي في src/pages/ باحثًا عن ملفات .astro، ويحوّل كل
مسار ملف إلى مسار توجيه، ويستدعي injectRoute مرة لكل لغة غير افتراضية — ثلاثة
مسارات إضافية لكل صفحة، لـ ar و fr و de. وتُقدَّم الإنجليزية بلا بادئة.
ويزوّد ملف مشترك هو src/i18n/route-manifest.ts كلًا من هذا الحاقن ومصرّف
عناوين Paraglide بسلاسل slug المترجمة، فيصبح /about/this-website متاحًا أيضًا
على /fr/a-propos/ce-site.
لا يحتوي التكامل عمدًا على وحدة تشغيل افتراضية. وقبل الحقن يتحقق من فرادة
اللغات، ووجود اللغة الافتراضية والمسارات المترجمة والمستثناة، والشرطة البادئة،
وتطابق المعاملات الديناميكية، والتصادمات — وهذه الأخيرة تُقاس أمام صفحات المصدر
نفسها كما تُقاس بعضها ببعض، لأنّ Astro يرى جدول توجيه واحدًا. فصفحة في
src/pages/ar/about.astro تُرفض: فهي أصلًا على العنوان الذي تُحقن عنده النسخة
العربية من /about. وتعيش أقلمة الروابط وعكسها وقت التشغيل في
src/i18n/routes.ts وتفوَّضان إلى Paraglide مع لغة صريحة. وتبقى الصفحة غير
الموجودة في الخريطة ذات مسار محلي بسلسلتها الأصلية، مثل /ar/auth/sign-in؛
أمّا المسار المترجم لبعض اللغات فقط فيصدر عنه تحذير، لأنّه يعود صامتًا إلى الأصل.
يحدث حقن المسارات مرة واحدة عند الإعداد، ولا يعيد Astro تشغيل ذلك الخطّاف إلا
لملف طُلب منه مراقبته. لذلك يُسجَّل ملف المسارات عبر addWatchFile، ويقوم
astro:server:setup — وهو يراقب src/pages/ بحثًا عن ملفات .astro مضافة أو
محذوفة — بلمس ذلك الملف بعد تهدئة 100 ملّي ثانية بدلًا من إعادة تشغيل الخادم
بنفسه. وهذا الالتفاف هو المقصود: فدالة restart() الخاصة بـ Vite تعيد البناء
من الإعداد الذي حلّه Astro سابقًا، فتعود حاملةً جدول التوجيه القديم، وتطبع سطر
سجل مطمئنًا بينما تبقى الصفحة الجديدة بالإنجليزية وحدها.
ولا يشحن التكامل وحدة تشغيل، لكنه يولّد نوعًا. فـ astro:config:done يكتب
اتحادًا محيطيًا هو LocalizedRoutes.SourceRoute — عضو واحد لكل صفحة وجدها — وتُقيَّد
localizedHref به، فيفشل ربط خطأ إملائي أو صفحة أُعيدت تسميتها عند الترجمة بدل أن
يعطي 404 في الإنتاج. وبناؤه من المسح نفسه الذي يغذّي injectRoute هو ما يمنع
تباعد المسارات التي يمكن ربطها عن المسارات الموجودة فعلًا.
apps/web/src/.generated
مدخلان للبناء تنتجهما الأدوات بدل أن تُكتبا يدويًا. وكلاهما مُدرَج في النسخ، لكن سبب إدراجهما ليس واحدًا.
strapi-client/ عميل مُنمَّط لواجهة Strapi — client.ts و types.ts و
index.ts — يكتبه pnpm --filter admin generate، الذي يجلب المخطَّط من Strapi
قيد التشغيل على المنفذ 3333. شغّل نظام إدارة المحتوى قبل إعادة التوليد.
والناتج معلَّم في ترويسته بـ @ts-nocheck وبعبارة «لا تحرّره يدويًا»، ويحمل
بصمة المخطَّط الذي وُلِّد منه، فيظهر أي تغيير في نوع محتوى لم يُعِد أحد التوليد
بعده في فَرق بدل أن يظهر في وقت التشغيل وحده. وهو مُدرَج في النسخ، وهذا ما يتيح
لنسخة نظيفة أن تمرّ فحص الأنواع والبناء دون أي نظام إدارة محتوى يعمل في أي مكان.
ثلاثة عشر ملفًّا تصل إليه عبر الاختصار $strapi، لكن واحدًا منها فقط ينشئ
كائنًا: src/core/cms/client.ts يبني نسخة StrapiClient الوحيدة بعنوان
الأساس والرمز المميّز القادمين من astro:env/server. وrepository.ts واحد
في إحدى الميزات يجذب الحارس isStrapiErrorOf، والباقي يستورد أنواعًا فقط —
BlogGetPayload و TagGetPayload و HeroGetPayload وما شابه، وهي أنواع
يضيّقها ملف types.ts الخاص بكل ميزة إلى الشكل الذي تريده مكوّناتها. فالعميل
مفرد بينما سطح أنواعه يُستخدم بحرية، وهذا هو الفصل المطلوب.
repo-tree.json هو شجرة الملفات التي تصيّرها هذه الصفحة، وينتجها
scripts/generate-repo-tree.mjs من git ls-files. وهو يحمل عدد الملفات وعدد
المجلّدات والأبناء المتداخلة — ولا شيء غير ذلك. وهذا الملف مُدرَج في
النسخ، مع أن لا شيء في محتواه يقتضي ذلك: فهو دالة خالصة لقائمة الملفات
المتتبَّعة، وdev وbuild وtest وtypecheck كلٌّ منها يعيد توليده قبل أن
يعمل. الذي يقتضيه هو Turborepo: فالشجرة تُبنى من git ls-files عند جذر
المستودع، بينما يحسب turbo بصمة مدخلاته لكل حزمة على حدة، فكان الملف الذي
يُضاف تحت apps/admin/ يغيّر هذه الصفحة دون أن يغيّر بصمة بناء الويب — فتقدّم
إصابةُ الذاكرة المؤقتة الشجرةَ القديمة. وإدراجه في النسخ يضع الشجرة داخل تلك
البصمة، ويُسقِط الأمر pnpm --filter web gen:tree:check في CI طلبَ الدمج متى
انحرفت النسخة المُدرَجة. وقد كان يختم فيه أيضًا معرّف SHA الخاص بـ HEAD، وهو
فعلًا الشيء الوحيد الذي لا يمكن لملف مولَّد ومُدرَج في النسخ أن يحمله — فكتابة
الملف تقدّم HEAD — وبقي ذلك الخَتم محذوفًا حين عاد الملف إلى التتبّع.
والمجلّد بأكمله مُدرَج في .prettierignore، على أساس أن إعادة التوليد ستُلغي
أي تنسيق طُبِّق عليه، وأن فحص التنسيق في CI سيُبلِّغ آنذاك عن ملفات لم يكتبها
أحد.
apps/web/src/core/controller.ts
كل سلوك في المتصفّح على هذا الموقع هو صنف مشتقّ من Controller. لا يوجد
<script> سائب يعبث بالـ DOM: كتلة السكربت تستورد صنفها وتستدعي
Controller.mount(id, resolve)، وهذا السطر وحده هو كامل سطح التسجيل.
والسبب هو انتقالات العرض. فـ Astro يستبدل المستند بدل أن يعيد تحميله، ومن ثمّ
فإن مستمعًا يُربط على astro:page-load ولا يُزال أبدًا يتراكم بنسخة حيّة لكل
تنقّل، مربوطة بعقد لم يعد المستند يحتويها. لذلك التنظيف هنا هو الأصل لا فكرة
لاحقة: يسجّل listen() و own() مُتخلِّصًا في لحظة التسجيل نفسها، ويفكّها
disconnect() بترتيب معكوس. وكانت دورة الحياة سابقًا مفردة AstroLifecycle
منفصلة، لكن Controller كان مستدعيها الوحيد، فطُويت الاثنتان في هذا الملف
الواحد.
يعمل سكربت الوحدة مرّة واحدة لكل جلسة، لا مرّة لكل صفحة، فـ resolve هو
البوّابة التي تقرّر إن كان في الصفحة الحالية شيء يُركَّب — و byId و
bySelector و whenPresent هي الثلاثة المعتادة، وألّا يُرجَع شيء أمر عاديّ لا
خلل، فلا يُسجَّل له شيء. والمفتَحة بـ id تعني أن إعادة التسجيل تستبدل بدل أن
تكرّر. أمّا التركيب والتفكيك فيتسلسلان عبر طابور وعود واحد، وهو ما يمنع تركيبًا
غير متزامن من التداخل مع تفكيك الصفحة التي يحلّ محلّها.
ولا تمرّر مواضع الاستدعاء أيّ إعداد كوسائط. فكل خُطّاف خصيصة data-* يمسحها
المتحكّم عند الاتصال، فيبقى الترميز في ملف .astro حيث يقيم باقي الصفحة أصلًا.
وتبني ثلاث قواعد على هذا العرف: core/filter-controller.ts يأخذ prefix و
keys، ويبقي الاستعلام والأوجه في العنوان، ويترك present و resultMessages و
onCriteriaChange لأصنافه المشتقّة؛ و core/form-controller.ts يقرأ نصوصه من
data-strings ويملك سطر الحالة وقفل الإرسال وربط أخطاء الحقول في حالة 400؛ و
design-system/patterns/carousel/EmblaController.ts يمسح data-embla-*، ويأخذ
اتّجاهه من dir فلا تحتاج الصفحات العربية أيّ توصيل، ويشترط وجود حاوية النقاط
ولو كانت فارغة، لأن معالج النقر مفوَّض إليها.
apps/web/src/design-system
عرضٌ بلا معرفة بالمجال. لا يجوز لهذا الدليل أن يستورد من features/ أو shell/
أو i18n/؛ والاعتماد الوحيد الذي يأخذه هو core/controller، لأن ثلاثة من
أنماطه سلوك لا ترميز.
والرموز ليست في هذا الدليل أصلًا. فـ Tailwind v4 بلا ملفّ إعداد بلغة JavaScript،
لذا تقيم في حزمة أعلى داخل packages/tailwind-config/theme.css. تحمل كتلة
@theme بسيطة القيم الخام — --leading-display: 0.92 و
--leading-heading: 0.95 و --tracking-eyebrow: 0.2em، و --text-eyebrow
بارتفاع سطرها الخاص --text-eyebrow--line-height كي لا يُترك أصغر خطّ على
النسبة الافتراضية — بينما تربط كتلة @theme inline الأسماء الدلالية بمتغيّرات
اللوحة، وهو ما يحوّل --color-background إلى أداة bg-background.
ويسجّل تعليقان في ذلك الملف قرارين لا وصفًا لشيفرة. فالوضع الداكن استعلام وسائط
prefers-color-scheme صريح: كانت النسخة المعتمدة على صنف تضع صنفها من JavaScript
عند DOMContentLoaded دون احتياط في CSS، فكان زوّار الوضع الداكن يُخدَمون
باللوحة الفاتحة ويرونها 532 ملّي ثانية مقيسة. واستعلام الوسائط يُحسم قبل أوّل
رسم، فلا يمكن أن يقع ذلك الوميض. ولا يوجد رمز لحركة الدخول: فـ
--animate-fade-in كان يستخدم نمط الملء both، فيُبقي كل غلاف يلمسه عند
opacity: 0 حتى تبدأ حركته، ممّا جعل عنصر LCP غير مستقرّ وكلّف نحو 100 ملّي
ثانية. والتعليقان موجودان كي لا يعود أيّ من القرارين خِلسةً.
و Typography مكوّن مركّب فوق وصفة tv() واحدة: أربعة عشر متغيّرًا ومحور
نبرة، تُعرَض ثابتةً من .astro ولا تُروى أبدًا. وحيث يتعذّر المكوّن تكون الدالّة
typography() الخام هي المخرج: فـ transition:name و set:html و class:list
وتمرير الفتحات والعنصر التفاعلي و <time> تحتاج جميعها سلسلة الأصناف لا الغلاف.
والعناصر الأوّلية قليلة عن قصد: Chip مع chip.ts، و Surface على
tone/radius/padding، وأشكال Input، و Button و Link يتقاسمان وصفة
interactive واحدة، و Icon. وعتبة إضافة واحد جديد مستهلكان اثنان لا واحد.
ويجمع patterns/ القطع المركّبة التي ما زالت لا تعرف ما المشروع — EmptyState
و FilterControls و FilterBottomSheet و AnimatedTooltip و MarkdownBody و
PixelSignal و card-transitions وعارض Embla الدوّار. وحتى المخرجات المولَّدة
تأخذ لونها بالطريقة نفسها: فسِمة Shiki المكتوبة يدويًا في lib/shiki.ts مبنيّة
على var(--color-foreground) و color-mix فوق خلفية شفّافة، فتقرأ سِمة واحدة
قراءةً صحيحة في النمطين بدل أن تلزم سِمتان.
apps/web/src/design-system/primitives/Icon.astro
ثلاثة عشر سطرًا، خمسة منها التعليق الذي يشرح سبب وجود الملف — وهو أصغر عناصر نظام
التصميم الأوّلية وأطولها تبريرًا. وهو يعيد تصدير Icon الخاص بـ astro-icon مع
خصيصة واحدة مفروضة:
<AstroIconBase {...Astro.props} is:inline />
كل مكوّن في التطبيق يستورد من هنا ولا يستورد من astro-icon/components أبدًا.
والسبب وضع فشل لا ينتج أي خطأ من أي نوع. فافتراضيًا يزيل astro-icon تكرار الرسوم
المجمّعة: أول تصيير لاسم أيقونة معيّن يُصدِر تعريف <symbol>، وكل تصيير لاحق
مجرّد <use href="#...">. وترتيب المستند هو ما يحدّد مَن يُحسَب أولًا — وفي هذا
التطبيق قد يكون ذلك وسمًا داخل فتحة جزيرة astro (شريط التنقّل والقائمة
الجانبية)، وهو ما يسلّمه Astro داخل <template data-astro-template> خامد حتى
الإماهة. فلا يدخل الرمز شجرة DOM الحيّة أبدًا، وكل <use> يشير إليه لا يحلّ إلى
شيء ولا يصيّر شيئًا. والمقيس، على شاشة مكتب: أيقونات السهم-يمينًا الثماني
والعشرون كلها، مع أيقونتَي GitHub والرابط في التذييل، اختفت.
وتضمين الأيقونات داخل الجزر وحدها لا يصلح الأمر، لأن التصييرات المضمّنة تُقدِّم
عدّاد astro-icon لكل اسم مع ذلك — فمسار الرسوم المجمّعة والمسار المضمّن يتشاركان
الدفتر نفسه، فالسياسة المختلطة تنقل فقط أيّ النسخ يختفي. وفرض is:inline في كل
مكان هو الضبط المتّسق الوحيد، بثمن تكرار وسم SVG لكل نسخة.
وtests/repo-guards.test.ts يحرس النصف الآخر من المشكلة، مستخدمًا
scripts/icons/scan.ts ليتحقّق من أن كل اسم pixelarticons:* مُشار إليه في
المصدر موجود فعلًا في مجموعة الأيقونات المثبَّتة، ويُفشل الفحص إن استورد أي شيء
lucide-react. وكان السكربت القديم الذي حلّ هذا محلّه غير موصول بـ CI
إطلاقًا، فهذا أول تشغيل تلقائي لكلا الفحصين.
apps/web/src/features
اثنتا عشرة وحدة ميزة — about و auth و blogs و contact و entries و
hero و og و profile و projects و search و tags و testimonials —
واحدة لكل مجال. ولا يقيم هنا شيء مشترك: فـ core/ يحوي صنف Controller
الأساس وعميل Strapi ومحمّلات مجموعات المحتوى، و design-system/ يحوي العرض بلا
معرفة بالمجال، و shell/ يحوي الإطار الذي ترتديه كل صفحة.
والفكرة الناظمة أن يُسمّى الملف باسم دوره لا باسم نوعه، وأن تتسلسل الأدوار.
فـ repository.ts هو الموضع الوحيد المسموح له بلمس عميل Strapi المولَّد: يملك
أشكال populate والمرشّحات وترتيب الفرز، ويعيد أنواع Strapi الخام. ولا تستدعيه
صفحة قطّ — إذ يوصّله content.config.ts بمحمّل يعمل مرّة لكل مزامنة، ويُعلَن
شرط الصلاحية الخاص بالميزة بجانبه هناك، فيسمّي السجلّ الفاسد مجموعته ومعرّف
مدخلته بدل أن يظهر كتأكيد مرميّ في منتصف العرض. ويجلس service.ts فوق مخزن
المحتوى ويقرأه ثانيةً عبر getCollection أو getEntry. والصفحة تنتظر الخدمة
وتمرّر النتيجة إلى الأسفل، و ui/ يعرض ما يُعطى له ولا يجلب شيئًا بنفسه. و
controller.ts — أو controllers/<name>.ts حيث تملك الميزة عدّة متحكّمات — هو
النصف الخاص بالمتصفّح. و types.ts يحمل أشكال المجال، وتحمل عدّة ميزات
translation-keys.ts يربط قيم تعدادات المجال بمفاتيح التعريب في موضع واحد بدل
تناثر وصل النصوص عبر المكوّنات.
والتسمية بالدور هي ما يمنع الموضع من أن يكون سؤالًا: فالوحدة التي تجلب من Strapi
لها موضع واحد بالضبط، والملف الذي يصعب تسميته هو غالبًا ملف يفعل شيئين. وهذا
أيضًا سبب خلوّ الشجرة من أدلّة components/ و utils/ و constants/، ومن أي
ملفّات برميل إطلاقًا — فلا شيء يعيد تصدير جاره، وكل استيراد يسمّي الوحدة التي
يريدها فعلًا. وتمرّ الاستيرادات بين الميزات عبر الاسم المستعار $/ ولا تصل إلّا
إلى service.ts الخاص بالأخرى، فلا ينتج عن نقل دليل فرقٌ مليء بتصحيحات
../../...
و auth هي الاستثناء المقصود، وتستحقّ القراءة قبل نسخ شكل أي شيء آخر هنا: بلا
repository وبلا service، لأنها تملك جلسة، والجلسة لا توجد إلّا في متصفّح.
apps/web/src/features/search
بحث الموقع ساكن بالكامل. يجمّع service.ts مصفوفة SearchDoc[] لكل لغة في
وقت البناء — المشاريع والمدوّنات ومُدخَلات الخطّ الزمني والوسوم، لكلٍّ منها
مُحوِّل واحد في mappers.ts، إضافة إلى مستندات مسارات ومراسٍ للصفحة الرئيسية
مكتوبة يدويًا في pages.ts، فيصير البحث تنقّلًا أيضًا. ويُقدَّم الناتج ملفَّ
JSON عاديًا من src/pages/search-index/[locale].json.ts، وهو نقطة نهاية
بامتداد .ts عن قصد: فمُحمِّل localized-static-routes لا يمشي إلا على ملفات
.astro، فلا يحقن أبدًا صيغًا مسبوقة باللغة، وتبقى المسارات في داخله حرفية.
ولا تزال دقيقة واحدة تسكن service.ts: لأن /projects/[slug] و
/blogs/[slug] يولّدان مساراتهما الساكنة من بيانات اللغة الافتراضية وحدها،
تقيم صفحة التفاصيل في كل لغة على سبيكة (slug) اللغة الافتراضية — لذا تحلّ
روابط البحث السبائك من الكيان الإنجليزي القانوني، مفهرسًا بـ documentId، لا
من الكيان المترجَم الذي تصفه. أمّا دقيقة ثانية فقد زالت: كان الفهرس لكل لغة
يُحفَظ مؤقتًا بوصفه وعدًا لا قيمة، لأن نقطة النهاية تُصيَّر أربع مرات في كل
بناء، وكان كل تصيير سيعيد بغير ذلك جلب كل مجموعة من Strapi. أمّا قراءة
المجموعة القانونية فصارت بحثًا في المخزن، فحُذف ذلك الحفظ المؤقت.
والمطابقة لا تقيم في هذه الميزة إطلاقًا. فـ $/lib/fuzzy هو الوحدة الوحيدة في
التطبيق التي تستورد Fuse، ونفس الفهرس يدعم كذلك مرشِّحَي صفحتَي المشاريع
والوسوم، فترتّب الأسطح الثلاثة النتائج ترتيبًا متطابقًا. وengine.ts يكيّف
SearchDoc[] عليه ويضيف الفصل بين المتعادلات حسب النوع، وgrouping.ts يملك
سقوف كل نوع والسقف الإجمالي — وهو يُسقِط مجموعات كاملة منخفضة الترتيب بدل أن
يقتطع صفوفًا من مجموعة تُبلِّغ أصلًا عن فائض. وكلاهما يعمل في المتصفّح ونقيّ
عن قصد: $/lib/text وأنواعه الخاصة، لا غير.
ولوحة البحث تحت ui/ واحدة من الجزر التفاعلية القليلة الحقيقية في الموقع،
وهي اليوم عدّة ملفات صغيرة لا ملفًّا واحدًا كبيرًا. SearchPalette.tsx هو
الغلاف ويُماه بـ client:idle؛ وuse-palette-open.ts وuse-palette-search.ts
يحملان قطعتَي الحالة اللتين كانتا متشابكتين بداخله؛ وPaletteInput.tsx
وPaletteResults.tsx وPaletteRowItem.tsx وPaletteStates.tsx تتولّى
التصيير، ووصفات أصنافها في palette-styles.ts. ولا يجلب أيٌّ منها شيئًا:
فـ worker-client.ts يُطلِق search.worker.ts عند أول فتح — أو عند جلب مسبق
بمرور المؤشّر — ويُبقي عميلًا واحدًا لكل رابط فهرس على مستوى الوحدة، فيبقى
الفهرس حيًّا عبر انتقال العرض، ويرتدّ إلى تشغيل المحرّك نفسه على الخيط الرئيسي
حيث يتعذّر إنشاء عامل وحدات (module worker).
apps/web/src/i18n
يصرّف Paraglide الترجمات من كتالوجات JSON الحالية المقسّمة إلى نطاقات. ويبقى
config.ts مصدر الحقيقة لـ Locale وlocales وdefaultLocale؛ ويطابقه
project.inlang/settings.json في اللغات الأربع ويثبّت إضافة الكتالوج الرسمية.
وتكتب إضافة Vite وحدات message-modules منمّطة ومتجاهلة في
src/.generated/paraglide/.
messages.ts هو الآن واجهة الوصول الكاملة إلى الرسائل في التطبيق، لا المساعد
الصغير الذي كانه من قبل. تقرأ ملفات .astro، ووحدات .ts التي تصل إليها هي
فقط، الرسائل عبر useMessages(locale) — وهو واجهة وسيطة لكل لغة فوق تلك الحزمة
المولَّدة، مفهرَسة بمفاتيح الكتالوج نفسها (t['header:language.label']()) بدلًا
من استيراد دوال رسائل منفردة. تخزّن جداول تسميات التعداد بيانات LabelRef —
مفتاح كتالوج، أو { key, context } — تُحل عبر label(t, ref). ولا يستدعي أي
مسار بناء setLocale()، فلا تتسرّب حالة اللغة بين الصفحات أثناء بناء Astro
المتوازي.
وما يمكن لـ Astro شحنه إلى المتصفح هو الاستثناء: جزر React، والوحدات التي لا يصل
إليها إلا سكربت Astro، وكل ما تستورده هذه الوحدات — ثمانية ملفات في المجمل — لا
تزال تستورد دوال الرسائل المولَّدة التي تحتاجها بعينها وتمرّر { locale }
صراحةً، حفاظًا على تشذيب الكتالوج في حزمها بدل سحبه كاملًا عبر الحزمة الأساسية.
يحسب tests/i18n/paraglide.test.ts تلك المجموعة القابلة للوصول من العميل ويفشل
البناء إذا استوردت أي وحدة فيها الحزمة الأساسية بدل ذلك.
يواصل مدقّق الكتالوج رفض انحراف اللغات والنطاقات والمفاتيح والقيم غير الصحيحة
والعناصر البديلة القديمة واختلاف الإحلال وفئات الجمع الناقصة. كما يصرّف
tests/i18n/paraglide.test.ts المشروع ويتحقق من تطابق الإعدادات ومفاتيح السياق
وتنسيق الأرقام وفئات الجمع العربية الست.
apps/web/src/styles/view-transitions.css
تُضبط هنا كل عمليات التنقل المتحركة في الموقع، في ملف أنماط واحد بدلاً من
تفريقها بين المكوّنات. يستبدل <ClientRouter /> الخاص بـ Astro المستند بدل
إعادة تحميله، وتحوّل واجهة View Transition في المتصفح هذا الاستبدال إلى حركة —
وهذا الملف هو ما يقرر شكل تلك الحركة.
يحدث أمران عند التنقل. تتلاشى الصفحة نفسها تلاشياً متقاطعاً، ويتحوّل كل عنصر
موسوم بأنه مشترك من موضعه السابق إلى موضعه الجديد: كأن ينمو غلاف بطاقة مشروع
ليصير الصورة الرئيسية في صفحة التفاصيل. إضافة زوج جديد تكلّف سمتين على كل جانب
ودون أي JavaScript — transition:name لإقرانهما، مع data-vt-image أو
data-vt-text لاختيار النمط الجاهز.
القواعد خارج أي طبقة عن قصد. يُصدر Astro قواعد الانتقال الخاصة به داخل
@layer astro، والقواعد خارج الطبقات تتغلب على القواعد داخلها، فلا شيء هنا
يحتاج إلى !important.
معظم الملف تعليقات، وهي تسجّل قياسات لا تفضيلات. أُعطي كل من تلاشي الصفحة
وتحوّل العناصر المشتركة منحنىً سريع البداية في البداية، واضطر كلاهما إلى
التغيير: فبأخذ عيّنات من حركة المتصفح إطاراً بإطار، تبيّن أن ثلثي الحركة يقعان
في الإطارات الثلاثة الأولى، وهو ما يُقرأ كقفزة لا كانتقال. الأرقام مدوّنة في
الملف، ويعيد tests/projects/transitions.test.ts اشتقاقها من هذه المتغيرات،
فيسقط أي تراجع في الاختبارات بدل أن يُنشر.
قاعدة واحدة مكتوبة على هيئة غياب. لا يُمنح الرأس ولا التذييل أسماء انتقال خاصة
بهما عن قصد: فتسمية عنصر تجعله جذر خلفية، وقرص الرأس المصنفر يعتمد على
backdrop-filter وهو يلتقط الصفحة خلفه — فكانت تسميته تُسطّح أثر الزجاج بصمت
في كل صفحة. ويُبقي تلاشي الصفحة اللقطة الخارجة معتمة تحته، ولذلك لم تحتج هذه
العناصر يوماً إلى انتزاعها: هي ببساطة لا تبدو متغيّرة.
وقطعة واحدة من هذا كلّه لا يمكن التعبير عنها في CSS، وهي تقيم في
design-system/patterns/card-transitions.ts. فـ transition:name لا يحمل إلّا
قيمة واحدة، وغلاف المشروع يحمل عن قصد الاسم نفسه في الصفحة الرئيسية وفي
الفهرس وفي بطل صفحة التفصيل — وهذا التشارك هو ما يجعل التحوّل يعمل من أيّ من
القائمتين بلا توصيل لكل صفحة. والأثر الجانبي أن القائمتين تتشاركان الاسم عندئذٍ
إحداهما مع الأخرى: فالانتقال من الرئيسية إلى الفهرس كان يقرن الأغلفة المميّزة
الأربعة ويطيّرها من 2752 بكسلًا تحت حدّ الطيّ. ويضيف
CardTransitions.install() مستمعًا واحدًا على astro:before-preparation يقرأ
sourceElement، ويصعد إلى سلفه [data-vt-card]، ويضع
viewTransitionName = 'none' سطريًّا على أهداف كل بطاقة أخرى — سطريًّا، لأن
الأسماء تأتي من قواعد صفحة الأنماط ولا يمكن إزالتها ببساطة، ولا شيء يعيدها لأن
الموجّه يطرح هذا المستند أصلًا. والتنقّل في السجلّ لا يحمل sourceElement،
فتبقى كل بطاقة بلا اسم، وهذا بالضبط ما يمنع رحلة العودة من الطيران. ولا يوجد
هذا المساعد إلّا ليُبعد التحوّل عن البطاقات الخطأ؛ وهو لا يعرف شيئًا عن
المشاريع ولا المدوّنات، ولهذا يجلس بين أنماط نظام التصميم لا داخل إحدى
الميزتين اللتين تستدعيانه.
turbo.json
سبع مهامّ، وبلا أيّ معرفة بما هما التطبيقان فعلًا. فـ build و lint و
lint:fix و typecheck و sync و test و dev تعلن الترتيب والتخزين
المؤقّت ولا شيء غير ذلك؛ أمّا كيفية حدوث البناء فتبقى في سكربتات package.json
الخاصة بكل تطبيق. و dev مضبوط على cache: false, persistent: true كي يُبقيه
Turbo موصولًا بدل أن يحاول تخزين خادم مؤقّتًا.
والمدخلة المثيرة هي sync، وهي موجودة لحلّ تسابق لا لتنفيذ خطوة بناء طلبها
أحد. فـ apps/web/astro.config.mjs يضبط cacheDir: '.astro'، وهو ما يجمع عن
قصد مخزن طبقة المحتوى على ملفّ واحد — apps/web/.astro/data-store.json — كي
يقرأ Vitest و astro sync و astro check المحتوى نفسه بدل النسختين
المنفصلتين اللتين ينتجهما node_modules/.astro الافتراضي. وثمن هذا التجميع أن
typecheck و test صارا يكتبان الملفّ نفسه، وTurbo يشغّل المهامّ المستقلّة
على التوازي: فتصادما في التكامل المستمرّ وأخفقا بـ ENOENT أثناء إعادة تسمية
data-store.json.tmp. وإعلان sync ومنح المهمّتين
dependsOn: ["^…", "sync"] يجعل المخزن يوجد مرّةً واحدة قبل أن يقرأه أيّ
منهما. و sync هي المهمّة الوحيدة التي مخرجها .astro/**.
و web#typecheck مضبوط على cache: false، وهذا قصدٌ لا سهو: فـ astro check
يزامن محتوى النظام الحيّ عبر كل محمّل، ولا يمكن لأي بصمة inputs أن ترى سجلًّا
تغيّر في Strapi، فالمرور المخزَّن مؤقّتًا سيخبر عن محتوى لم يعد موجودًا. أمّا
apps/web/turbo.json على مستوى التطبيق فلا يضيف إلّا قوائم env: المتغيّرات
الخمسة الخاصة بـ Astro، معلنةً لكل مهمّة، لأن Turbo يعمل في وضع البيئة الصارم
ويحذف بصمت كل ما لم يُعلَن.
هذا الموقع وما سبقه
خمس نسخ في عامين ونصف. كل واحدة حلّت مشكلة سابقتها وجاءت بمشكلتها — غالبًا بالقفز إلى إطار عمل قبل أن تكبر المشكلة بما يكفي لتبريره.
v1فبراير 2024 – أغسطس 2025
4 commits
صفحات مكتوبة باليد
- طريقة العرض
- ملفات ثابتة
- المحتوى
- مكتوب داخل الوسم مباشرة
- الخادم
- لا شيء
- المستودعات
- Hadi-Hijazi · HadiHz88.github.io
مستودعان، وأربعة commits، وبلا أي خطوة بناء: HTML وملف أنماط مدفوعان إلى GitHub Pages. لا شيء يُترجم، ولا شيء يُنشر، ولا شيء يُدفع ثمنه — وما زالت أسرع نسخة عرفها هذا الموقع. وانتهت لسبب بديهي: المحتوى والتصميم في الملف نفسه، فإضافة مشروع تعني نسخ كتلة من الوسم وتذكّر كل موضع آخر يجب تعديله.
v2مارس – أبريل 2025
50 commit
Laravel، وواجهة لم أكتبها
- طريقة العرض
- عرض من جهة العميل
- المحتوى
- قاعدة بيانات خلف Laravel
- الخادم
- PHP و Node
- المستودعات
- My-Platform-BackEnd · My-Platform-FrontEnd
أردت خلفية حقيقية — قاعدة بيانات، ولوحة إدارة، ومصادقة — وأعطاني Laravel الثلاثة في بعد ظهيرة واحدة. تعلّمه كان معظم الغاية، وهذا الجزء نجح. أما الواجهة فقد وُلِّدت ولم تُكتب، فلم تصر لي يومًا. وبعد ثلاثة أسابيع اتضح شكل المشكلة: تطبيقان، ونشران، وخادم يعمل على مدار الساعة ليقدّم صفحة تتغيّر مرتين في الشهر.
v3يوليو – سبتمبر 2025
144 commit
البنية نفسها، بلغة واحدة
- طريقة العرض
- عرض من جهة العميل
- المحتوى
- قاعدة بيانات خلف NestJS
- الخادم
- Node
- المستودعات
- my-website-backend · My-Website-Client
إعادة كتابة الواجهة البرمجية بـ NestJS أعادت الشيفرة إليّ: لغة واحدة على الطرفين، وأنواع مشتركة من البداية إلى النهاية، وتجربة يومية أفضل بكثير. لكن ما لم يتغيّر هو البنية. بقيت واجهة CRUD مكتوبة يدويًا لبضع عشرات من السجلات، وبقي تطبيق صفحة واحدة يرسم شاشة فارغة قبل أن يرسم المحتوى، وبقي شيئان يجب إبقاؤهما منشورَين لموقع لا يسجّل أحد الدخول إليه.
v4نوفمبر 2025 – فبراير 2026
92 commit
ثابت، والمحتوى داخل المستودع
- طريقة العرض
- معدّ مسبقًا عند البناء
- المحتوى
- ملفات JSON داخل المستودع
- الخادم
- لا شيء
- المستودعات
- HadiHz-Portfolio
حذف الخلفية حلّ مشكلة الأداء دفعة واحدة. كان Next.js يُعدّ كل صفحة مسبقًا، والمحتوى يجلس في ملفات JSON بجانب الشيفرة، ولم يبقَ وقت تشغيل يمكن أن يبطئ أو يسقط. لكن الكلفة انتقلت ولم تختفِ: كل خطأ مطبعي صار commit، وJSON سطح بائس للكتابة، ولغة ثانية كانت ستعني صيانة ملفات متوازية يدويًا.
v5أبريل 2026 – حتى الآن
857 commit
Astro و Strapi
- طريقة العرض
- معدّ مسبقًا، مع جزر
- المحتوى
- Strapi
- الخادم
- لا شيء عند القراءة
- المستودعات
- hadihz.me
هذه النسخة تحتفظ بنتيجة سابقتها وتعيد إمكانية الكتابة. يحتفظ Strapi بالمحتوى، ويُعدّ Astro كل صفحة مسبقًا، ولا يُرسَل أي JavaScript ما لم يطلبه مكوّن — شجرة الملفات أعلاه والمخطط أدناه جزيرتان، وكل ما حولهما HTML خالص. والمقايضة الباقية ظاهرة لا مخفية: النشر يتطلب إعادة بناء، ولهذا يوجد خط نشر.
الـ commits شهريًا
النسخ الخمس نفسها، مقيسة. خط لكل نسخة، متقطّع منذ أن توقفت عن تشغيل الموقع.
1,147 commit في هذه المستودعات
البيانات حتى 30/9/2026