Dwie marki, jedna aplikacja Rails — host constraints w routes.rb + namespace dev + inteligentne helpery + trzy prawdziwe pułapki (slug catch-all, drift nav, brak sign-out).
how2claude ma dwie strony:
how2claude.com — strona ogólna, pokrywa wszystkie kategorie użycia Claude (getting-started / prompting / use-cases / tools / comparisons / claude-code)how2claude.dev — zorientowana na developerów, pokazuje tylko kategorię claude-code, osobna marka, płaskie URL-e, ciemny motyw terminalu95% treści jest współdzielone (te same dane artykułu), 20% prezentacji musi się rozdzielić (marka, nav, struktura URL, motyw). Dwa repozytoria Rails oczywiście się nie opłaca — system artykułów, płatności, konta, OAuth, i18n, całe utrzymanie podwojone. Ale jedna baza kodu, dwie domeny ma stertę granic do narysowania: jak dzielą się trasy, czy controllery są reużywane, jak view wie na jakiej domenie działa, gdzie layout się rozgałęzia.
Pozwoliłem Claude'owi to zbudować. To co następuje to kompletny zapis gdzie leżą linie rozgałęzienia — praca feature sprzed 5 dni, obie strony teraz w produkcji.
Rozłóż dzielone vs. rozgałęzione od razu:
Dzielone (jeden kod, obie domeny używają):
- Model artykułu, Category, Series
- Login OAuth, subskrypcja Stripe, płatności x402
- Strona konta /accounts (ta sama feature, ten sam UI)
- Strona cennika /pricing (te same dane, te same kontrolki Stimulus)
- Mechanizm locale i18n, 19 plików językowych
Rozgałęzione (różne między domenami):
- Strona główna: .com przeglądanie multi-kategoria vs. .dev design specyficzny dla developerów (hero $ claude --master)
- Struktura URL: .com /:category/:slug vs. .dev /:slug (płaskie)
- Widoczna treść: .dev pokazuje tylko artykuły kategorii claude-code
- Motyw: .com białe tło z akcentami orange vs. .dev ciemny monospace emerald
- Nawigacja: .com ma linki kategorii vs. .dev uproszczony do Pricing + Sign in
Centralna zasada: nie ruszaj warstwy danych, rozgałęziaj controllery gdzie trzeba, rób view / helpery świadome hosta.
Całe forkowanie płynie z 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
Dwie uwagi:
how2claude\.dev przez \A, dopasuj opcjonalne porty przez (:\d+)?. how2claude.test to lokalna domena developmentu (przez dnsmasq lub /etc/hosts wskazujący na 127.0.0.1); dodanie jej do wzorca sprawia że trasy dev działają też lokalnie.scope "(:locale)", prefiksy URL jak /zh, /ja działają na obu stronach bez przepisywania.Ten sam PagesController#pricing jest podpięty do obu bloków — jedna metoda controllera, różne route helpery: .com używa pricing_path, .dev używa dev_pricing_path. Ta sztuczka z aliasem unika "kolizji nazw helperów" (dwie domeny nazywające swój helper /pricing jako pricing spowodowałyby że późniejsza definicja nadpisze pierwszą).
Powiedz Claude'owi żeby to zrobił a jego pierwszy instynkt to tworzyć Dev::XxxController dla każdej strony. Źle — większość controllerów jest dzielona między oboma domenami. Rób namespace tylko gdy zachowanie faktycznie się rozchodzi.
W praktyce tylko dwa:
# 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 jest zablokowany na Category.find_by!(slug: "claude-code") — to ograniczenie warstwy danych dla domeny .dev. /:slug szuka po slug artykułu bezpośrednio; inaczej niż .com nie potrzebuje /:category_slug/:slug.
ContentGate to concern paywalla, dzielony między obiema stronami — reguły płatne identyczne, różnią się tylko ścieżki wejścia.
Szablony view nie powinny zawierać if request.host.include?("how2claude.dev") — brzydkie, nie DRY. Wyciągnij helpery:
# 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
Potem każdy dzielony view (articles/_row.html.erb, articles/show.html.erb, okruszki, sidebar) używa smart_article_path(article, category) zamiast article_path(...).
smart_category_path zwraca dev_root_path na .dev — bo .dev nie ma osobnej strony kategorii (cała strona to jedna kategoria), linki kategorii powinny wskazywać na home.
Te helpery pozwalają jednemu szablonowi view zachowywać się poprawnie na obu domenach bez rozgałęziania view.
app/views/layouts/dev.html.erb i application.html.erb są rozgałęzione — ciemny vs. jasny, monospace vs. sans, emerald vs. orange. Ale każdy funkcjonalny link pozostaje wyrównany z głównym layoutem:
<!-- 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>
Wizualne się rozgałęzia, ale Pricing / Sign in / Account / przełącznik locale — żaden z tych funkcjonalnych linków nie znika. Wpadłem w to (poniżej).
/:slug zjadł /pricing (commit 23163cc)Trasy dev zaczęły wyglądać tak:
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
Odwiedzenie how2claude.dev/pricing → dopasowuje /:slug → Dev::ArticlesController#show → Category.find_by!(slug: "claude-code").articles.find_by!(slug: "pricing") → ActiveRecord::RecordNotFound → 404.
Fix: zadeklarować /pricing jawnie przed /: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
Trasy Rails dopasowują się w kolejności deklaracji — /pricing musi iść przed /:slug żeby pierwszym zgarnąć dopasowanie.
Bonusowa pułapka: alias musi być dev_pricing, nie pricing — bo blok .com już ma as: :pricing. Rails nie rzuci błędu; późniejsza definicja cicho nadpisuje pierwszą, więc pricing_path na .com zacząłby generować URL podpięty do bloku .dev. Kiedy Claude napisał to pierwszy raz, użył as: :pricing; złapałem to dopiero gdy uderzyłem w /pricing na .com i zauważyłem że coś nie gra — string URL /pricing wyglądał poprawnie, ale cel trasy się zmienił (a ponieważ obie trasy uderzały w PagesController#pricing, bug był cichy).
Reguła: gdy ta sama akcja jest podpięta do wielu bloków tras, każdy blok potrzebuje unikalnej nazwy as.
eac4b2f)Pierwszy raz gdy kazałem Claude'owi zbudować dev.html.erb napisał uproszczoną wersję — logo marki + przełącznik locale tylko. Uzasadnienie: "strona dev to strona treści, nav czysta."
Wdrożyłem. Zarejestrowałem nowe konto na .dev — brak linku Sign in. Musiałem ręcznie wpisać /session/new. Potem przyszli płacący użytkownicy — brak linku Pricing. Ręczne wpisywanie /pricing.
To nie jest czysty design — to brakujące feature'y. eac4b2f skopiował core nav z .com, przestylizowany na ciemny motyw.
Reguła: wizualne mogą się rozgałęzić (ciemny/jasny, monospace/sans) ale funkcjonalność musi pozostać wyrównana. Każdy core entry point jaki ma jedna strona (Pricing, Sign in, Account, locale) drugiej też potrzebuje, chyba że jest jasny powód produktowy żeby nie.
Kiedy prosisz Claude'a żeby zbudował rozgałęziony layout, powiedz wyraźnie: "zachowaj wszystkie funkcjonalne wejścia, zmień tylko wizualne." Jego default to "uprościć," a po stronie użytkownika "uprościć" czyta się jako "brakujący feature."
/accounts nie miał przycisku Sign-out (commit 92b34a8)Nie bezpośrednio związane z multi-domeną, ale wyskoczyło w tym samym okresie.
Template session w Rails 8 generuje trasę DELETE /session ale nigdzie nie wystawia przycisku. Jedyny sposób żeby użytkownicy mogli się wylogować:
curl -X DELETE https://how2claude.com/session -H "Cookie: ..."
Wyraźnie nieakceptowalne. 92b34a8 dodał stonowany button_to "Sign out" na dole /accounts z turbo_confirm żeby uniknąć przypadkowych kliknięć.
Reguła: gdy audytujesz kompletność feature'a, nie śledź tylko ścieżki do przodu. Rejestracja, logowanie, płatność, użycie — to Claude pokrywa kompleksowo. Ścieżki wstecz (wylogowanie, anulowanie subskrypcji, usunięcie konta) są pomijane. Kazanie Claude'owi wyliczyć "każde przejście stanu użytkownika" daje szersze pokrycie niż proszenie "zrób stronę konta."
Hosty produkcyjne to how2claude.com i how2claude.dev; lokalnie musimy symulować oba. /etc/hosts:
127.0.0.1 how2claude.test
127.0.0.1 dev.how2claude.test
Potem constraint trasy dodaje .test jako lokalny alias dla .dev:
constraints(host: /\A(how2claude\.dev|how2claude\.test)(:\d+)?\z/) do
(W powyższym, how2claude.test efektywnie jest lokalnym aliasem dla .dev. Lokalny ruch .com uderza w domyślne localhost albo 127.0.0.1 — żaden host constraint nie pasuje, wpada w domyślny blok tras.)
W praktyce: bin/rails s -b 127.0.0.1 -p 3000 działa, potem uderz how2claude.test:3000 w przeglądarce żeby testować trasy .dev, localhost:3000 dla .com. Dwie domeny, jeden proces Rails, zmień kod i oba się przeładowują.
Niech Claude uruchomi dwie strony marek z jednej bazy kodu Rails — pełny checklist:
constraints(host:) w routes.rb, umieszczając przed blokiem domyślnym (Rails dopasowuje się w kolejności deklaracji).as żeby uniknąć kolizji./pricing przed catch-all /:slug, albo zostaną połknięte.smart_article_path / on_dev_domain? trzymają view agnostyczne do hosta. Żadnych if request.host.include?(...) w szablonach./etc/hosts + domeny .test lokalnie żeby symulować domenę dev. Dodaj .test jako alias dev w constraint trasy.Techniczna trudność uruchomienia dwóch stron marek z jednej bazy kodu Rails nie jest wysoka — constraints(host:) plus abstrakcje helperów tam cię prowadzą. Co naprawdę potrzebuje twojej uwagi to dyscyplina rozgałęziania: co jest dzielone, co się rozgałęzia, kiedy rozgałęzić, kiedy scalić. Claude potrafi napisać kod poprawnie, ale nie zdecyduje za ciebie czy dwie strony powinny wyglądać tak samo. To jest osąd produktowy.