שני מותגים, אפליקציית 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, מותג נפרד, URL שטוחים, ערכת נושא כהה טרמינלית95% מהתוכן משותף (אותם נתוני מאמר), 20% מההצגה צריך להסתעף (מותג, ניווט, מבנה URL, ערכת נושא). שני ריפוזיטורי Rails ברור שלא שווה — מערכת מאמרים, תשלומים, חשבונות, OAuth, i18n, כל התחזוקה מוכפלת. אבל בסיס קוד אחד, שני דומיינים יש ערימה של גבולות לצייר: איך המסלולים נפרדים, האם ה-controller עובר שימוש חוזר, איך ה-view יודע על איזה דומיין הוא רץ, איפה ה-layout מסתעף.
נתתי ל-Claude לבנות את זה. מה שבא הוא תיעוד מלא של איפה נמצאים קווי ההסתעפות — עבודת feature לפני 5 ימים, שני האתרים עכשיו בפרודקשן.
מיפוי משותף מול מסתעף מראש:
משותף (קוד אחד, שני הדומיינים משתמשים):
- מודל מאמר, Category, Series
- login OAuth, מנוי Stripe, תשלומי x402
- דף חשבון /accounts (אותה תכונה, אותו UI)
- דף תמחור /pricing (אותם נתונים, אותם controls של 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
עקרון מרכזי: אל תיגע בשכבת הנתונים, הסתעף controllers היכן שצריך, עשה views / helpers מודעים למארח.
כל ה-forking זורם מ-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 routes לעבוד גם מקומית.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 לכל עמוד. שגוי — רוב ה-controllers משותפים בין שני הדומיינים. רק 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 / switcher 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 הוא כתב גרסה מצומצמת — לוגו מותג + switcher locale בלבד. נימוק: "אתר dev הוא אתר תוכן, nav נקייה."
העליתי. רשמתי חשבון חדש ב-.dev — אין קישור Sign in. נאלצתי להקליד /session/new ידנית. אז באו משתמשים בתשלום — אין קישור Pricing. להקליד /pricing ידנית.
זה לא עיצוב נקי — אלה תכונות חסרות. eac4b2f העתיק את ליבת ה-nav של .com, מעוצב מחדש לערכת נושא כהה.
כלל: הוויזואלי יכול להסתעף (כהה/בהיר, monospace/sans) אבל הפונקציונליות חייבת להישאר מיושרת. כל נקודת כניסה ליבתית שיש באתר אחד (Pricing, Sign in, Account, locale) השני צריך, אלא אם יש סיבה מוצרית מפורשת שלא.
כשמבקשים מ-Claude לבנות layout מסתעף, אמור בפירוש: "שמור על כל הכניסות הפונקציונליות, שנה רק ויזואלי." ברירת המחדל שלו היא "לפשט," ובצד המשתמש "לפשט" נקרא כ"תכונה חסרה."
/accounts לא היה כפתור Sign-out (commit 92b34a8)לא קשור ישירות למולטי-דומיין, אבל צף באותה תקופה.
ה-template של session ב-Rails 8 מייצר מסלול DELETE /session אבל אף פעם לא חושף את הכפתור בשום מקום. הדרך היחידה למשתמשים להתנתק:
curl -X DELETE https://how2claude.com/session -H "Cookie: ..."
ברור שלא מקובל. 92b34a8 הוסיף button_to מושתק של "Sign out" בתחתית /accounts עם turbo_confirm למניעת קליקים בשוגג.
כלל: כשמבצעים אודיט שלמות של תכונה, אל תעקבו רק אחרי הנתיב קדימה. הרשמה, login, תשלום, שימוש — את אלה Claude מכסה מקיפות. נתיבים לאחור (sign out, ביטול מנוי, מחיקת חשבון) מוחמצים. לבקש מ-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 כברירת מחדל — אין host constraint שמתאים, נופל לבלוק המסלולים הדיפולטיבי.)
בפועל: 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-all /: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 יכול לכתוב את הקוד נכון, אבל הוא לא יחליט בשבילך האם שני האתרים צריכים להיראות אותו דבר. זה שיפוט מוצרי.