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, מותג נפרד, 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 מודעים למארח.

ארכיטקטורה: בלוק host-constraint ב-routes.rb

כל ה-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

שתי הערות:

  1. regex המארח חייב להיות מדויק — עגן את הקידומת how2claude\.dev עם \A, תאם פורטים אופציונליים עם (:\d+)?. how2claude.test הוא דומיין פיתוח מקומי (דרך dnsmasq או /etc/hosts שמצביע ל-127.0.0.1); הוספתו לדפוס גורמת ל-dev routes לעבוד גם מקומית.
  2. scope ה-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 יגרמו להגדרה המאוחרת לדרוס את הראשונה).

controllers של namespace dev: רק כשההסתעפות נדרשת

תגיד ל-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, משותף בין שני האתרים — כללי התשלום זהים, רק נתיבי הכניסה שונים.

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: ערכת נושא + ניווט, אבל שמור על משמעת

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 — אף אחד מהקישורים הפונקציונליים האלה לא נעלם. דרכתי על זה (למטה).

מלכודת #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 הוא כתב גרסה מצומצמת — לוגו מותג + switcher locale בלבד. נימוק: "אתר dev הוא אתר תוכן, nav נקייה."

העליתי. רשמתי חשבון חדש ב-.dev — אין קישור Sign in. נאלצתי להקליד /session/new ידנית. אז באו משתמשים בתשלום — אין קישור Pricing. להקליד /pricing ידנית.

זה לא עיצוב נקי — אלה תכונות חסרות. eac4b2f העתיק את ליבת ה-nav של .com, מעוצב מחדש לערכת נושא כהה.

כלל: הוויזואלי יכול להסתעף (כהה/בהיר, monospace/sans) אבל הפונקציונליות חייבת להישאר מיושרת. כל נקודת כניסה ליבתית שיש באתר אחד (Pricing, Sign in, Account, locale) השני צריך, אלא אם יש סיבה מוצרית מפורשת שלא.

כשמבקשים מ-Claude לבנות layout מסתעף, אמור בפירוש: "שמור על כל הכניסות הפונקציונליות, שנה רק ויזואלי." ברירת המחדל שלו היא "לפשט," ובצד המשתמש "לפשט" נקרא כ"תכונה חסרה."

מלכודת #3: ל-/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 אחד — רשימת בדיקה מלאה:

  1. מפה משותף מול מסתעף קודם — אילו מודולים תואמים, אילו לא. אל תפתח namespaces מראש.
  2. עטוף בלוק dev עם constraints(host:) ב-routes.rb, מקם לפני הבלוק הדיפולטיבי (Rails מתאים בסדר ההכרזה).
  3. controllers של namespace dev רק כשההתנהגות מתפצלת. controllers ניתנים לשיתוף (PagesController, AccountsController) מתחברים לשני בלוקי המסלולים עם שמות as ייחודיים למניעת התנגשות.
  4. הכרז מפורשות על עמודים ספציפיים כמו /pricing לפני catch-all /: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 יכול לכתוב את הקוד נכון, אבל הוא לא יחליט בשבילך האם שני האתרים צריכים להיראות אותו דבר. זה שיפוט מוצרי.