Free

Niech Claude uruchomi dwie strony marek z jednej bazy kodu Rails (how2claude.com + how2claude.dev)

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 terminalu

95% 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.


Centralne pytanie: co jest dzielone, co się rozgałęzia

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.

Architektura: blok host-constraint w routes.rb

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:

  1. Regex hosta musi być precyzyjny — zakotwicz prefiks 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.
  2. Scope locale zagnieżdża się wewnątrz host constraint — oba bloki tras owijają 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ą).

Controllery namespace dev: tylko gdy forkowanie wymagane

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.

Mądre helpery: trzymaj view w nieświadomości hosta

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.

Rozgałęzienie layoutu: motyw + nav, ale zachowaj dyscyplinę

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).

Pułapka #1: catch-all /: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 /:slugDev::ArticlesController#showCategory.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.

Pułapka #2: dev nav zgubił Pricing + Sign in (commit 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."

Pułapka #3: /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."

Development lokalny

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ą.

Checklist

Niech Claude uruchomi dwie strony marek z jednej bazy kodu Rails — pełny checklist:

  1. Rozłóż dzielone vs. rozgałęzione najpierw — które moduły się zgadzają, które nie. Nie otwieraj namespaces od razu.
  2. Owinąć blok dev w constraints(host:) w routes.rb, umieszczając przed blokiem domyślnym (Rails dopasowuje się w kolejności deklaracji).
  3. Controllery namespace dev tylko gdy zachowanie się rozchodzi. Współdzielone controllery (PagesController, AccountsController) podpina się do obu bloków tras z unikalnymi nazwami as żeby uniknąć kolizji.
  4. Zadeklaruj wyraźnie specyficzne strony jak /pricing przed catch-all /:slug, albo zostaną połknięte.
  5. Helpery smart_article_path / on_dev_domain? trzymają view agnostyczne do hosta. Żadnych if request.host.include?(...) w szablonach.
  6. Rozgałęziaj wizualne w layoucie ale trzymaj wejścia funkcjonalne wyrównane. Powiedz Claude'owi "zachowaj całą funkcjonalność, zmień tylko wizualne" — jego default to upraszczać, a to czyta się jako utratę feature'a.
  7. Audytuj ścieżki wstecz osobno. Wylogowanie, anulowanie, usuwanie są pomijane; proszenie Claude'a żeby wyliczył "każde przejście stanu użytkownika" pokrywa więcej niż proszenie "zrób każdą stronę."
  8. Używaj /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.