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, .devdev_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 може написати код правильно, але він не вирішить за вас, чи мають два сайти виглядати однаково. Це продуктове судження.