Free

Lasciare che Claude faccia girare due siti di brand da un solo codebase Rails (how2claude.com + how2claude.dev)

Due brand, una app Rails — host constraints in routes.rb + namespace dev + helper intelligenti + tre trappole reali (slug catch-all, drift della nav, sign-out mancante).


how2claude ha due siti:

  • how2claude.com — il sito generale, copre tutte le categorie di uso di Claude (getting-started / prompting / use-cases / tools / comparisons / claude-code)
  • how2claude.dev — orientato agli sviluppatori, mostra solo la categoria claude-code, brand separato, URL piatti, tema scuro da terminale

Il 95% del contenuto è condiviso (stessi dati articolo), il 20% della presentazione deve biforcarsi (brand, nav, struttura URL, tema). Due repo Rails ovviamente non vale la pena — sistema articoli, pagamenti, account, OAuth, i18n, tutta la manutenzione raddoppiata. Ma un codebase, due domini ha un mucchio di confini da tracciare: come si separano le rotte, i controller si riusano?, come fa la view a sapere su quale dominio gira?, dove si biforca il layout?

Ho lasciato che Claude costruisse questo. Quel che segue è il resoconto completo di dove cadono le linee di biforcazione — lavoro di feature di 5 giorni fa, entrambi i siti ora in produzione.


Domanda centrale: cosa è condiviso, cosa si biforca

Mappa condiviso vs. biforcato fin dall'inizio:

Condiviso (un codice, entrambi i domini lo usano):
- Modello articolo, Category, Series
- Login OAuth, abbonamento Stripe, pagamenti x402
- Pagina account /accounts (stessa feature, stessa UI)
- Pagina pricing /pricing (stessi dati, stessi controlli Stimulus)
- Meccanismo locale i18n, 19 file di lingua

Biforcato (diverso tra domini):
- Home: .com navigazione multi-categoria vs. .dev design specifico dev (hero $ claude --master)
- Struttura URL: .com /:category/:slug vs. .dev /:slug (piatto)
- Contenuto visibile: .dev mostra solo articoli della categoria claude-code
- Tema: .com sfondo bianco con accenti orange vs. .dev scuro monospace emerald
- Navigazione: .com ha link di categoria vs. .dev semplificato a Pricing + Sign in

Principio centrale: non toccare il layer dati, biforca i controller dove serve, rendi view / helper consapevoli dell'host.

Architettura: il blocco host-constraint in routes.rb

Tutto il forking scorre da 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

Due note:

  1. La regex dell'host deve essere precisa — ancora il prefisso how2claude\.dev con \A, matcha porte opzionali con (:\d+)?. how2claude.test è il dominio di dev locale (via dnsmasq o /etc/hosts che punta a 127.0.0.1); aggiungerlo al pattern fa funzionare le rotte dev anche in locale.
  2. Lo scope locale si annida dentro host constraint — entrambi i blocchi di rotta avvolgono scope "(:locale)", i prefissi URL come /zh, /ja funzionano su entrambi i siti senza riscrivere.

Lo stesso PagesController#pricing è agganciato a entrambi i blocchi — un metodo controller, diversi route helper: .com usa pricing_path, .dev usa dev_pricing_path. Questo trucco di alias evita una "collisione di nome helper" (due domini che nominano il loro helper di /pricing come pricing farebbero sì che la definizione successiva sovrascriva la prima).

Controller di namespace dev: solo quando è richiesto biforcare

Dì a Claude di fare questo e il primo istinto è creare Dev::XxxController per ogni pagina. Sbagliato — la maggior parte dei controller è condivisa tra entrambi i domini. Fai namespace solo quando il comportamento diverge realmente.

In pratica solo due:

# 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 è bloccato su Category.find_by!(slug: "claude-code") — questo è il vincolo del layer dati per il dominio .dev. /:slug cerca per slug articolo direttamente; a differenza di .com non ha bisogno di /:category_slug/:slug.

ContentGate è il concern del paywall, condiviso tra entrambi i siti — regole di pagamento identiche, solo i percorsi di ingresso differiscono.

Helper intelligenti: tieni le view all'oscuro dell'host

I template view non dovrebbero contenere if request.host.include?("how2claude.dev") — brutto, non DRY. Estrai gli helper:

# 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

Poi ogni view condivisa (articles/_row.html.erb, articles/show.html.erb, breadcrumb, sidebar) usa smart_article_path(article, category) invece di article_path(...).

smart_category_path ritorna dev_root_path su .dev — perché .dev non ha una pagina di categoria separata (l'intero sito è una categoria), i link di categoria dovrebbero puntare alla home.

Questi helper permettono a un singolo template view di comportarsi correttamente su entrambi i domini senza biforcare la view.

Biforcazione del layout: tema + nav, ma resta disciplinato

app/views/layouts/dev.html.erb e application.html.erb sono biforcati — scuro vs. chiaro, monospace vs. sans, emerald vs. orange. Ma ogni link funzionale resta allineato con il layout principale:

<!-- 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>

Il visivo si biforca, ma Pricing / Sign in / Account / switcher locale — nessuno di questi link funzionali scompare. Sono caduto in questa (sotto).

Trappola #1: il catch-all /:slug ha ingoiato /pricing (commit 23163cc)

Le rotte dev all'inizio apparivano così:

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

Visitare how2claude.dev/pricing → matcha /:slugDev::ArticlesController#showCategory.find_by!(slug: "claude-code").articles.find_by!(slug: "pricing")ActiveRecord::RecordNotFound → 404.

Fix: dichiarare /pricing esplicitamente prima di /: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

Le rotte Rails matchano in ordine di dichiarazione/pricing deve venire prima di /:slug per rivendicare il match per primo.

Trappola bonus: l'alias deve essere dev_pricing, non pricing — perché il blocco .com ha già as: :pricing. Rails non darà errore; la definizione successiva sovrascrive silenziosamente la prima, quindi pricing_path su .com inizierebbe a generare un URL legato al blocco .dev. Quando Claude ha scritto questo la prima volta, ha usato as: :pricing; l'ho beccato solo quando sono andato su /pricing su .com e ho notato qualcosa di strano — la stringa URL /pricing sembrava corretta, ma il target della rotta era cambiato (e poiché entrambe le rotte colpivano PagesController#pricing, il bug era silenzioso).

Regola: quando la stessa action è agganciata a più blocchi di rotta, ogni blocco ha bisogno di un as unico.

Trappola #2: la nav dev ha perso Pricing + Sign in (commit eac4b2f)

La prima volta che ho fatto costruire dev.html.erb a Claude ha scritto una versione ridotta — logo del brand + switcher locale solamente. Motivazione: "il sito dev è un sito di contenuti, nav pulita."

Inviato. Ho registrato un nuovo account su .dev — niente link Sign in. Ho dovuto digitare /session/new a mano. Poi sono arrivati utenti paganti — niente link Pricing. Digitare /pricing a mano.

Questo non è design pulito — sono feature mancanti. eac4b2f ha copiato il core della nav .com, ristilizzato per il tema scuro.

Regola: il visivo può biforcarsi (scuro/chiaro, monospace/sans) ma la funzionalità deve restare allineata. Ogni entry point core che un sito ha (Pricing, Sign in, Account, locale) l'altro ne ha bisogno, a meno che ci sia una ragione di prodotto esplicita per non averlo.

Quando chiedi a Claude di costruire un layout biforcato, dillo esplicitamente: "mantieni tutti gli ingressi funzionali, cambia solo il visivo." Il suo default è "semplificare," e dal lato utente "semplificare" si legge come "feature mancante."

Trappola #3: /accounts non aveva un pulsante Sign-out (commit 92b34a8)

Non direttamente legato a multi-dominio, ma emerso nello stesso periodo.

Il template session di Rails 8 genera una rotta DELETE /session ma non espone mai il pulsante da nessuna parte. Unico modo per gli utenti di fare logout:

curl -X DELETE https://how2claude.com/session -H "Cookie: ..."

Chiaramente inaccettabile. 92b34a8 ha aggiunto un button_to muted "Sign out" in fondo a /accounts con turbo_confirm per evitare click accidentali.

Regola: quando audisci la completezza di una feature, non tracciare solo il percorso diretto. Signup, login, pagare, usare — questo Claude lo copre in modo esaustivo. I percorsi inversi (sign out, annulla abbonamento, elimina account) vengono persi. Far enumerare a Claude "ogni transizione di stato utente" dà copertura più ampia che chiedergli "fai la pagina account."

Sviluppo locale

Gli host di produzione sono how2claude.com e how2claude.dev; localmente dobbiamo simulare entrambi. /etc/hosts:

127.0.0.1 how2claude.test
127.0.0.1 dev.how2claude.test

Poi la constraint di rotta aggiunge .test come alias locale per .dev:

constraints(host: /\A(how2claude\.dev|how2claude\.test)(:\d+)?\z/) do

(Nel sopra, how2claude.test è effettivamente l'alias locale per .dev. Il traffico .com locale colpisce localhost o 127.0.0.1 di default — nessuna host constraint matcha, cade nel blocco di rotta default.)

In pratica: bin/rails s -b 127.0.0.1 -p 3000 in esecuzione, poi colpisci how2claude.test:3000 nel browser per testare rotte .dev, localhost:3000 per .com. Due domini, un processo Rails, cambi il codice ed entrambi ricaricano.

Checklist

Lasciare che Claude faccia girare due siti di brand da un solo codebase Rails — checklist completa:

  1. Mappa condiviso vs. biforcato per primo — quali moduli coincidono, quali no. Non aprire namespace di entrata.
  2. Avvolgi il blocco dev con constraints(host:) in routes.rb, posizionandolo prima del blocco default (Rails matcha in ordine di dichiarazione).
  3. Controller di namespace dev solo quando il comportamento diverge. Controller condivisibili (PagesController, AccountsController) si agganciano a entrambi i blocchi di rotta con nomi as unici per evitare collisione.
  4. Dichiara esplicitamente pagine specifiche come /pricing prima dei catch-all /:slug, o vengono ingoiate.
  5. Helper smart_article_path / on_dev_domain? tengono le view agnostiche all'host. Niente if request.host.include?(...) dentro i template.
  6. Biforca i visivi nel layout ma tieni gli ingressi funzionali allineati. Di' a Claude "preserva tutta la funzionalità, cambia solo il visivo" — il suo default è semplificare, e quello si legge come perdita di feature.
  7. Audisci i percorsi inversi separatamente. Sign out, annulla, elimina vengono persi; chiedere a Claude di enumerare "ogni transizione di stato utente" copre più che chiedergli "fai ogni pagina."
  8. Usa /etc/hosts + dominio .test localmente per simulare il dominio dev. Aggiungi .test come alias dev nella constraint di rotta.

La difficoltà tecnica di far girare due siti di brand da un codebase Rails non è alta — constraints(host:) più astrazioni con helper ti portano lì. Quello che ha davvero bisogno della tua attenzione è la disciplina della biforcazione: cosa è condiviso, cosa si biforca, quando biforcare, quando fondere. Claude può scrivere il codice correttamente, ma non decide per te se i due siti dovrebbero apparire uguali. Quello è un giudizio di prodotto.