علامتان تجاريتان، تطبيق 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 واعية بالمضيف.
كل التفرع يتدفق من 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
ملاحظتان:
how2claude\.dev بـ \A، طابق المنافذ الاختيارية بـ (:\d+)?. how2claude.test هو نطاق التطوير المحلي (عبر dnsmasq أو /etc/hosts يشير إلى 127.0.0.1)؛ إضافته للنمط تجعل مسارات dev تعمل محلياً أيضاً.scope "(:locale)"، بادئات URL مثل /zh، /ja تعمل على كلا الموقعين دون إعادة كتابة.نفس PagesController#pricing مرتبط بكلتا الكتلتين — طريقة controller واحدة، route helpers مختلفة: .com يستخدم pricing_path، .dev يستخدم dev_pricing_path. خدعة الـ alias هذه تتجنب "تصادم أسماء helper" (نطاقان يسميان helper الخاص بـ /pricing بـ pricing يؤدي إلى أن التعريف اللاحق يكتب فوق الأول).
اطلب من 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، مشترك بين الموقعين — قواعد الدفع متطابقة، فقط مسارات الدخول تختلف.
قوالب 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.
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 — لا أحد من هذه الروابط الوظيفية يختفي. دست على هذه (أدناه).
/: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 → تطابق /:slug → Dev::ArticlesController#show → Category.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 فريد.
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 متفرع، قل بشكل صريح: "احتفظ بكل المداخل الوظيفية، غيّر فقط البصريات." افتراضه "التبسيط،" ومن جانب المستخدم "التبسيط" يُقرأ كـ "ميزة مفقودة."
/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 واحدة — قائمة مراجعة كاملة:
constraints(host:) في routes.rb، ضعها قبل الكتلة الافتراضية (Rails يتطابق بترتيب التصريح).as فريدة لتجنب التصادم./pricing بشكل صريح قبل catch-alls /:slug، وإلا فإنها تُبتلع.smart_article_path / on_dev_domain? تبقي views معزولة عن المضيف. لا if request.host.include?(...) داخل القوالب./etc/hosts + نطاق .test محلياً لمحاكاة نطاق dev. أضف .test كـ alias dev في constraint المسار.الصعوبة التقنية لتشغيل موقعي علامة تجارية من قاعدة كود Rails واحدة ليست عالية — constraints(host:) إضافة إلى تجريدات helper تأخذك إلى هناك. ما يحتاج اهتمامك فعلاً هو انضباط التفرع: ما المشترك، ما المتفرع، متى تتفرع، متى تدمج. Claude يستطيع كتابة الكود بشكل صحيح، لكنه لن يقرر لك ما إذا كان ينبغي أن يبدو الموقعان متشابهين. هذا حكم منتج.