Free

ترك Claude يشغّل موقعي علامة تجارية من قاعدة كود Rails واحدة (how2claude.com + how2claude.dev)

علامتان تجاريتان، تطبيق Rails واحد — host constraints في routes.rb + dev namespace + helpers ذكية + ثلاثة فخاخ حقيقية (slug catch-all، انحراف nav، غياب sign-out).


لدى how2claude موقعان:

  • how2claude.com — الموقع العام، يغطي جميع فئات استخدام Claude (getting-started / prompting / use-cases / tools / comparisons / claude-code)
  • how2claude.dev — موجه للمطورين، يُظهر فقط فئة claude-code، علامة تجارية منفصلة، روابط مسطحة، ثيم داكن طرفي

95% من المحتوى مشترك (نفس بيانات المقال)، 20% من العرض يحتاج تفرّعاً (العلامة التجارية، الـ nav، بنية URL، الثيم). مستودعا Rails بوضوح لا يستحق — نظام المقال، المدفوعات، الحسابات، OAuth، i18n، كل الصيانة تتضاعف. لكن قاعدة كود واحدة، نطاقان لديها كومة حدود ينبغي رسمها: كيف تنفصل المسارات، هل تُعاد controller، كيف يعرف الـ view على أي نطاق يعمل، أين يتفرّع الـ layout.

تركت Claude يبني هذا. ما يلي سجل كامل لأماكن خطوط التفرع — عمل feature قبل 5 أيام، كلا الموقعين يعمل الآن في الإنتاج.


السؤال الجوهري: ما المشترك، ما المتفرع

اعرض المشترك مقابل المتفرع منذ البداية:

مشترك (قطعة كود واحدة، كلا النطاقين يستخدمها):
- نموذج المقال، Category، Series
- تسجيل دخول OAuth، اشتراك Stripe، مدفوعات x402
- صفحة الحساب /accounts (نفس الميزة، نفس الـ UI)
- صفحة التسعير /pricing (نفس البيانات، نفس عناصر Stimulus)
- آلية locale للـ i18n، 19 ملف لغة

متفرع (مختلف عبر النطاقات):
- الصفحة الرئيسية: .com تصفح متعدد الفئات مقابل .dev تصميم خاص بالمطورين (hero $ claude --master)
- بنية URL: .com /:category/:slug مقابل .dev /:slug (مسطح)
- المحتوى المرئي: .dev يُظهر فقط مقالات فئة claude-code
- الثيم: .com خلفية بيضاء بلمسات orange مقابل .dev داكن monospace emerald
- التنقل: .com لديه روابط فئات مقابل .dev مبسّط لـ Pricing + Sign in

المبدأ الجوهري: لا تمس طبقة البيانات، فرّع controller عند الحاجة، اجعل views / helpers واعية بالمضيف.

المعمارية: كتلة host-constraint في routes.rb

كل التفرع يتدفق من config/routes.rb:

# how2claude.dev — developer site (Claude Code only, flat URLs)
constraints(host: /\A(how2claude\.dev|how2claude\.test)(:\d+)?\z/) do
  scope "(:locale)", locale: /zh|zh-TW|ja|ko|.../ do
    root "dev/home#index", as: :dev_root
    get "/pricing", to: "pages#pricing", as: :dev_pricing
    get "/:slug", to: "dev/articles#show", as: :dev_article
  end
end

# Public routes — default locale (en) has no prefix, others use /:locale
scope "(:locale)", locale: /zh|zh-TW|ja|ko|.../ do
  root "pages#home"
  get "/pricing", to: "pages#pricing", as: :pricing
  get "/:category_slug",       to: "categories#show", as: :category
  get "/:category_slug/:slug", to: "articles#show",   as: :article
end

ملاحظتان:

  1. regex الـ host يجب أن يكون دقيقاً — ثبّت بادئة how2claude\.dev بـ \A، طابق المنافذ الاختيارية بـ (:\d+)?. how2claude.test هو نطاق التطوير المحلي (عبر dnsmasq أو /etc/hosts يشير إلى 127.0.0.1)؛ إضافته للنمط تجعل مسارات dev تعمل محلياً أيضاً.
  2. نطاق locale متداخل داخل host constraint — كلتا كتلتي المسار تلفان scope "(:locale)"، بادئات URL مثل /zh، /ja تعمل على كلا الموقعين دون إعادة كتابة.

نفس PagesController#pricing مرتبط بكلتا الكتلتين — طريقة controller واحدة، route helpers مختلفة: .com يستخدم pricing_path، .dev يستخدم dev_pricing_path. خدعة الـ alias هذه تتجنب "تصادم أسماء helper" (نطاقان يسميان helper الخاص بـ /pricing بـ pricing يؤدي إلى أن التعريف اللاحق يكتب فوق الأول).

controller namespace dev: فقط عند الحاجة للتفرع

اطلب من Claude القيام بهذا وغريزته الأولى إنشاء Dev::XxxController لكل صفحة. خطأ — معظم الـ controller مشترك بين النطاقين. فقط عرّف namespace عندما يتباعد السلوك فعلاً.

في الممارسة فقط اثنان:

# app/controllers/dev/home_controller.rb
class Dev::HomeController < ApplicationController
  allow_unauthenticated_access
  layout "dev"

  def index
    @category = Category.find_by!(slug: "claude-code")
    @series = @category.series.order(:position)
    @standalone_articles = @category.articles
                                    .where(series: nil)
                                    .published
                                    .order(:position)

    first_free = @category.articles.published.where(free: true).order(:position).first
    @cta_article = first_free || @category.articles.published.order(:position).first
  end
end
# app/controllers/dev/articles_controller.rb
class Dev::ArticlesController < ApplicationController
  include ContentGate
  allow_unauthenticated_access
  layout "dev"

  def show
    @category = Category.find_by!(slug: "claude-code")
    @article = @category.articles.published.find_by!(slug: params[:slug])
    @series = @article.series
    @series_articles = @series.articles.published.order(:position) if @series
    gate_content!(@article)
  end
end

Dev articles مقيّد بـ Category.find_by!(slug: "claude-code") — هذا قيد طبقة البيانات لنطاق .dev. /:slug يبحث بـ slug المقال مباشرة؛ بخلاف .com لا يحتاج /:category_slug/:slug.

ContentGate هو concern الـ paywall، مشترك بين الموقعين — قواعد الدفع متطابقة، فقط مسارات الدخول تختلف.

helpers ذكية: أبقِ الـ views لا تعرف المضيف

قوالب view لا ينبغي أن تحتوي على if request.host.include?("how2claude.dev") — قبيح، ليس DRY. استخرج helpers:

# app/helpers/application_helper.rb
def on_dev_domain?
  request.host.include?("how2claude.dev")
end

def smart_article_path(article, category)
  if on_dev_domain?
    dev_article_path(slug: article.slug)
  else
    article_path(category_slug: category.slug, slug: article.slug)
  end
end

def smart_category_path(category)
  if on_dev_domain?
    dev_root_path
  else
    category_path(category_slug: category.slug)
  end
end

ثم كل view مشترك (articles/_row.html.erb، articles/show.html.erb، breadcrumbs، sidebar) يستخدم smart_article_path(article, category) بدلاً من article_path(...).

smart_category_path يُرجع dev_root_path على .dev — لأن .dev ليس لديه صفحة فئة منفصلة (الموقع كله فئة واحدة)، روابط الفئات يجب أن تشير إلى الصفحة الرئيسية.

هذه الـ helpers تتيح لقالب view واحد التصرف بشكل صحيح عبر كلا النطاقين دون تفريع الـ view.

تفرّع الـ layout: ثيم + nav، لكن ابقَ منضبطاً

app/views/layouts/dev.html.erb وapplication.html.erb متفرعان — داكن مقابل فاتح، monospace مقابل sans، emerald مقابل orange. لكن كل رابط وظيفي يبقى متوائماً مع الـ layout الرئيسي:

<!-- layouts/dev.html.erb -->
<nav class="...">
  <%= link_to "how2claude.dev", dev_root_path,
        class: "font-mono font-bold text-emerald-400..." %>

  <div class="flex items-center gap-4 text-sm">
    <%= link_to t("pricing.page_title"), dev_pricing_path, class: "..." %>

    <% if authenticated? %>
      <%= link_to t("nav.account"), accounts_path, class: "..." %>
    <% else %>
      <%= link_to t("nav.sign_in"), new_session_path, class: "..." %>
    <% end %>

    <!-- locale dropdown -->
  </div>
</nav>

البصريات تتفرع، لكن Pricing / Sign in / Account / مبدّل locale — لا أحد من هذه الروابط الوظيفية يختفي. دست على هذه (أدناه).

الفخ #1: catch-all /:slug ابتلع /pricing (commit 23163cc)

مسارات dev بدأت تبدو هكذا:

constraints(host: /how2claude\.dev/) do
  scope "(:locale)" do
    root "dev/home#index", as: :dev_root
    get "/:slug", to: "dev/articles#show", as: :dev_article
  end
end

زيارة how2claude.dev/pricing → تطابق /:slugDev::ArticlesController#showCategory.find_by!(slug: "claude-code").articles.find_by!(slug: "pricing")ActiveRecord::RecordNotFound → 404.

الإصلاح: صرّح عن /pricing بشكل صريح قبل /:slug:

 constraints(host: /how2claude\.dev/) do
   scope "(:locale)" do
     root "dev/home#index", as: :dev_root
+    get "/pricing", to: "pages#pricing", as: :dev_pricing
     get "/:slug", to: "dev/articles#show", as: :dev_article
   end
 end

مسارات Rails تتطابق بترتيب التصريح/pricing يجب أن يأتي قبل /:slug ليطالب بالمطابقة أولاً.

فخ إضافي: الـ alias يجب أن يكون dev_pricing، وليس pricing — لأن كتلة .com لديها بالفعل as: :pricing. Rails لن يُظهر خطأً؛ التعريف اللاحق يكتب بصمت فوق الأول، لذا pricing_path على .com سيبدأ يولّد URL مربوط بكتلة .dev. عندما كتب Claude هذا أول مرة، استخدم as: :pricing؛ اكتشفته فقط عندما ضربت /pricing على .com ولاحظت أن شيئاً ما خطأ — نص URL /pricing بدا صحيحاً، لكن هدف المسار تغير (ولأن كلا المسارين يضربان PagesController#pricing، الخطأ صامت).

قاعدة: عندما يُربط نفس action بكتل مسار متعددة، كل كتلة تحتاج اسم as فريد.

الفخ #2: nav dev فاتته Pricing + Sign in (commit eac4b2f)

أول مرة طلبت من Claude بناء dev.html.erb كتب نسخة مجردة — شعار العلامة التجارية + مبدّل locale فقط. المنطق: "موقع dev هو موقع محتوى، النقاش نظيف."

أرسلت. سجلت حساباً جديداً على .dev — لا رابط Sign in. اضطررت لكتابة /session/new يدوياً. ثم جاء المستخدمون المدفوعون — لا رابط Pricing. كتابة /pricing يدوياً.

هذا ليس تصميماً نظيفاً — إنه ميزات مفقودة. eac4b2f نسخ core nav من .com، أعيد تصميمه للثيم الداكن.

قاعدة: البصريات يمكن أن تتفرع (داكن/فاتح، monospace/sans) لكن الوظيفة يجب أن تبقى متوائمة. كل نقطة دخول أساسية موجودة في موقع (Pricing، Sign in، Account، locale) الموقع الآخر يحتاجها، ما لم يكن هناك سبب منتج صريح لعدم ذلك.

عند طلب Claude لبناء layout متفرع، قل بشكل صريح: "احتفظ بكل المداخل الوظيفية، غيّر فقط البصريات." افتراضه "التبسيط،" ومن جانب المستخدم "التبسيط" يُقرأ كـ "ميزة مفقودة."

الفخ #3: /accounts لم يكن له زر Sign-out (commit 92b34a8)

ليس مرتبطاً مباشرة بالنطاقات المتعددة، لكنه ظهر في نفس الفترة.

قالب session لـ Rails 8 يولّد مسار DELETE /session لكن لا يُظهر الزر في أي مكان. الطريقة الوحيدة للمستخدمين لتسجيل الخروج:

curl -X DELETE https://how2claude.com/session -H "Cookie: ..."

غير مقبول بوضوح. 92b34a8 أضاف button_to "Sign out" مكتوم في أسفل /accounts مع turbo_confirm لتجنب النقرات العرضية.

قاعدة: عند تدقيق اكتمال الميزة، لا تتبع فقط المسار الأمامي. التسجيل، تسجيل الدخول، الدفع، الاستخدام — Claude يغطيها بشكل شامل. المسارات العكسية (تسجيل خروج، إلغاء اشتراك، حذف حساب) تُفوّت. جعل Claude يعدّد "كل انتقال حالة مستخدم" يعطي تغطية أوسع من طلب "اصنع صفحة الحساب."

التطوير المحلي

مضيفات الإنتاج هي how2claude.com وhow2claude.dev؛ محلياً نحتاج لمحاكاة كليهما. /etc/hosts:

127.0.0.1 how2claude.test
127.0.0.1 dev.how2claude.test

ثم constraint المسار يضيف .test كـ alias محلي لـ .dev:

constraints(host: /\A(how2claude\.dev|how2claude\.test)(:\d+)?\z/) do

(في ما سبق، how2claude.test فعلياً هو الـ alias المحلي لـ .dev. حركة مرور .com المحلية تضرب localhost أو 127.0.0.1 الافتراضي — لا constraint host يطابق، تسقط في كتلة المسار الافتراضية.)

في الممارسة: bin/rails s -b 127.0.0.1 -p 3000 يعمل، ثم اضرب how2claude.test:3000 في المتصفح لاختبار مسارات .dev، localhost:3000 لـ .com. نطاقان، عملية Rails واحدة، غيّر الكود وكلاهما يعيد التحميل.

قائمة المراجعة

ترك Claude يشغّل موقعي علامة تجارية من قاعدة كود Rails واحدة — قائمة مراجعة كاملة:

  1. اعرض المشترك مقابل المتفرع أولاً — أي الوحدات تتطابق، وأيها لا. لا تفتح namespaces منذ البداية.
  2. لفّ كتلة dev بـ constraints(host:) في routes.rb، ضعها قبل الكتلة الافتراضية (Rails يتطابق بترتيب التصريح).
  3. controller namespace dev فقط عند تفرّع السلوك. controllers قابلة للمشاركة (PagesController، AccountsController) ترتبط بكلتا كتلتي المسار بأسماء as فريدة لتجنب التصادم.
  4. صرّح عن صفحات محددة مثل /pricing بشكل صريح قبل catch-alls /:slug، وإلا فإنها تُبتلع.
  5. helpers smart_article_path / on_dev_domain? تبقي views معزولة عن المضيف. لا if request.host.include?(...) داخل القوالب.
  6. فرّع البصريات في الـ layout لكن أبقِ المداخل الوظيفية متوائمة. قل لـ Claude "احتفظ بكل الوظائف، غيّر فقط البصريات" — افتراضه التبسيط، وذلك يُقرأ كفقدان ميزة.
  7. دقّق المسارات العكسية بشكل منفصل. Sign out، إلغاء، حذف تُفوّت؛ طلب Claude تعداد "كل انتقال حالة مستخدم" يغطي أكثر من طلب "اصنع كل صفحة."
  8. استخدم /etc/hosts + نطاق .test محلياً لمحاكاة نطاق dev. أضف .test كـ alias dev في constraint المسار.

الصعوبة التقنية لتشغيل موقعي علامة تجارية من قاعدة كود Rails واحدة ليست عالية — constraints(host:) إضافة إلى تجريدات helper تأخذك إلى هناك. ما يحتاج اهتمامك فعلاً هو انضباط التفرع: ما المشترك، ما المتفرع، متى تتفرع، متى تدمج. Claude يستطيع كتابة الكود بشكل صحيح، لكنه لن يقرر لك ما إذا كان ينبغي أن يبدو الموقعان متشابهين. هذا حكم منتج.