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