Два бренда, одно Rails-приложение — host constraints в routes.rb + dev namespace + умные helpers + три реальные ловушки (catch-all slug, дрейф навигации, пропавший 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 — всё сопровождение удваивается. Но одна кодовая база, два домена — это куча границ, которые надо провести: как делятся маршруты, переиспользуются ли контроллеры, как view знает, на каком домене работает, где расходится layout.
Я поручил это Claude. Ниже полная запись того, где проходят линии ветвления — фича-работа 5-дневной давности, оба сайта сейчас в продакшене.
Разнесите общее vs. расходящееся сразу:
Общее (один код, оба домена используют):
- Модель статьи, Category, Series
- OAuth-логин, подписка Stripe, платежи x402
- Страница аккаунта /accounts (та же фича, тот же UI)
- Страница тарифов /pricing (те же данные, те же Stimulus-контролы)
- Механизм i18n-locale, 19 файлов языков
Расходящееся (разное между доменами):
- Главная: .com мульти-категорийный обзор vs. .dev дизайн специально для разработчиков (hero $ claude --master)
- Структура URL: .com /:category/:slug vs. .dev /:slug (плоский)
- Видимый контент: .dev показывает только статьи категории claude-code
- Тема: .com белый фон с акцентами orange vs. .dev тёмный monospace emerald
- Навигация: .com имеет ссылки категорий vs. .dev упрощён до Pricing + Sign in
Центральный принцип: не трогай слой данных, разветвляй контроллеры там, где нужно, делай view / 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 подключён к обоим блокам — один метод контроллера, разные route helpers: .com использует pricing_path, .dev использует dev_pricing_path. Этот трюк с alias избегает «столкновения имён helper» (два домена, называющие свой helper для /pricing как pricing, приведут к тому, что последнее определение перезапишет первое).
Скажите Claude это сделать, и его первый инстинкт — создать Dev::XxxController для каждой страницы. Неправильно — большинство контроллеров общие между обоими доменами. Делайте 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, хлебные крошки, сайдбар) использует 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 ветвлены — тёмный vs. светлый, monospace vs. sans, emerald vs. 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 (коммит 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-сайт — сайт контента, nav должна быть чистой».
Залил. Зарегистрировал новый аккаунт на .dev — нет ссылки Sign in. Пришлось печатать /session/new вручную. Потом пришли платные пользователи — нет ссылки Pricing. Печатать /pricing вручную.
Это не чистый дизайн — это пропавшие фичи. eac4b2f скопировал core nav из .com, перестилизовал под тёмную тему.
Правило: визуал может ветвиться (тёмный/светлый, monospace/sans), но функциональность должна оставаться выровненной. Каждая core-точка входа, которая есть у одного сайта (Pricing, Sign in, Account, locale), нужна и другому, если только нет явной продуктовой причины не делать.
Когда просите Claude построить разветвлённый layout, говорите явно: «сохрани все функциональные входы, меняй только визуал». Его дефолт — «упростить», а на стороне пользователя «упростить» читается как «пропавшая фича».
/accounts не было кнопки Sign-out (коммит 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 — 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? держат view агностичными к хосту. Никаких if request.host.include?(...) внутри шаблонов./etc/hosts + домен .test локально для эмуляции dev-домена. Добавьте .test как dev-alias в constraint маршрута.Техническая сложность запуска двух брендовых сайтов из одной кодовой базы Rails невысока — constraints(host:) плюс абстракции helper вас туда приведут. Что действительно требует вашего внимания — дисциплина ветвления: что общее, что ветвится, когда ветвить, когда сливать. Claude может написать код правильно, но он не решит за вас, должны ли два сайта выглядеть одинаково. Это продуктовое суждение.