Free

Пусть Claude запустит два брендовых сайта из одной кодовой базы Rails (how2claude.com + how2claude.dev)

Два бренда, одно 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 чувствительными к хосту.

Архитектура: блок 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. Регулярка host должна быть точной — якорь префикса how2claude\.dev через \A, опциональные порты матчить через (:\d+)?. how2claude.test — это локальный домен разработки (через dnsmasq или /etc/hosts, указывающий на 127.0.0.1); его добавление в паттерн делает dev-маршруты рабочими локально.
  2. Locale scope вкладывается внутрь host constraint — оба блока маршрутов оборачивают scope "(:locale)", URL-префиксы вроде /zh, /ja работают на обоих сайтах без переписывания.

Один и тот же PagesController#pricing подключён к обоим блокам — один метод контроллера, разные route helpers: .com использует pricing_path, .dev использует dev_pricing_path. Этот трюк с alias избегает «столкновения имён helper» (два домена, называющие свой helper для /pricing как pricing, приведут к тому, что последнее определение перезапишет первое).

Контроллеры namespace dev: только когда требуется ветвление

Скажите 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, общий между обоими сайтами — правила оплаты идентичны, отличаются только пути входа.

Умные helpers: держите view в неведении о хосте

Шаблоны 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.

Ветвление layout: тема + nav, но оставайтесь дисциплинированным

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 — ни одна из этих функциональных ссылок не пропадает. Наступил на это (ниже).

Ловушка #1: catch-all /: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 → совпадает с /: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: в dev-nav пропали Pricing + Sign in (коммит 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, говорите явно: «сохрани все функциональные входы, меняй только визуал». Его дефолт — «упростить», а на стороне пользователя «упростить» читается как «пропавшая фича».

Ловушка #3: у /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 — полный чеклист:

  1. Разнесите общее vs. ветвящееся в первую очередь — какие модули совпадают, какие нет. Не открывайте namespaces сразу.
  2. Оберните dev-блок с constraints(host:) в routes.rb, размещая перед дефолтным блоком (Rails совпадает в порядке объявления).
  3. Контроллеры namespace dev только когда поведение расходится. Разделяемые контроллеры (PagesController, AccountsController) подключаются к обоим блокам маршрутов с уникальными именами as для избежания столкновения.
  4. Явно объявляйте конкретные страницы вроде /pricing перед catch-all /:slug, или их проглотит.
  5. Helpers smart_article_path / on_dev_domain? держат view агностичными к хосту. Никаких if request.host.include?(...) внутри шаблонов.
  6. Ветвите визуалы в layout, но держите функциональные входы выровненными. Скажите Claude «сохрани всю функциональность, меняй только визуал» — его дефолт упрощать, и это читается как потеря фичи.
  7. Аудируйте обратные пути отдельно. Выход, отмена, удаление пропускаются; попросить Claude перечислить «каждый переход состояния пользователя» покрывает больше, чем попросить «сделай каждую страницу».
  8. Используйте /etc/hosts + домен .test локально для эмуляции dev-домена. Добавьте .test как dev-alias в constraint маршрута.

Техническая сложность запуска двух брендовых сайтов из одной кодовой базы Rails невысока — constraints(host:) плюс абстракции helper вас туда приведут. Что действительно требует вашего внимания — дисциплина ветвления: что общее, что ветвится, когда ветвить, когда сливать. Claude может написать код правильно, но он не решит за вас, должны ли два сайта выглядеть одинаково. Это продуктовое суждение.