Deux marques, une app Rails — host constraints dans routes.rb + namespace dev + helpers intelligents + trois pièges réels (slug catch-all, dérive nav, sign-out manquant).
how2claude a deux sites :
how2claude.com — le site général, couvrant toutes les catégories d'usage de Claude (getting-started / prompting / use-cases / tools / comparisons / claude-code)how2claude.dev — orienté développeur, n'affiche que la catégorie claude-code, marque distincte, URLs plates, thème sombre terminal95% du contenu est partagé (mêmes données d'articles), 20% de la présentation doit bifurquer (marque, nav, structure d'URL, thème). Deux dépôts Rails ne valent clairement pas le coup — le système d'articles, les paiements, les comptes, OAuth, i18n, tout en maintenance doublée. Mais une base de code, deux domaines comporte un tas de frontières à tracer : comment les routes se séparent, les controllers sont-ils réutilisés, comment la view sait-elle sur quel domaine elle tourne, où bifurque le layout.
J'ai laissé Claude construire ça. Ce qui suit est le relevé complet de l'emplacement des lignes de bifurcation — travail de feature d'il y a 5 jours, les deux sites maintenant en production.
Cartographie partagé vs. bifurqué d'entrée :
Partagé (un seul code, les deux domaines l'utilisent) :
- Modèle d'article, Category, Series
- Login OAuth, abonnement Stripe, paiements x402
- Page compte /accounts (même feature, même UI)
- Page pricing /pricing (mêmes données, mêmes contrôles Stimulus)
- Mécanisme locale i18n, 19 fichiers de langue
Bifurqué (différent selon les domaines) :
- Home : .com navigation multi-catégorie vs. .dev design spécifique dev (hero $ claude --master)
- Structure URL : .com /:category/:slug vs. .dev /:slug (plat)
- Contenu visible : .dev n'affiche que les articles de catégorie claude-code
- Thème : .com fond blanc avec accents orange vs. .dev sombre monospace emerald
- Navigation : .com a des liens de catégorie vs. .dev simplifié à Pricing + Sign in
Principe central : ne touche pas à la couche données, bifurque les controllers là où nécessaire, rends views / helpers conscients du host.
Tout le forking découle de 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
Deux notes :
how2claude\.dev avec \A, matcher les ports optionnels avec (:\d+)?. how2claude.test est le domaine de dev local (via dnsmasq ou /etc/hosts pointant vers 127.0.0.1) ; l'ajouter au pattern fait fonctionner les routes dev en local aussi.scope "(:locale)", les préfixes URL comme /zh, /ja fonctionnent sur les deux sites sans réécrire.Le même PagesController#pricing est accroché dans les deux blocs — une méthode controller, différents route helpers : .com utilise pricing_path, .dev utilise dev_pricing_path. Cette astuce d'alias évite une "collision de nom de helper" (deux domaines nommant leur helper de /pricing pricing feraient que la définition ultérieure écrase la première).
Dis à Claude de faire ça et son premier instinct est de créer Dev::XxxController pour chaque page. Faux — la plupart des controllers sont partagés entre les deux domaines. Seulement namespace quand le comportement diverge réellement.
En pratique seulement deux :
# 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 est verrouillé sur Category.find_by!(slug: "claude-code") — c'est la contrainte de couche données pour le domaine .dev. /:slug cherche par slug d'article directement ; contrairement à .com pas besoin de /:category_slug/:slug.
ContentGate est le concern du paywall, partagé entre les deux sites — règles de paiement identiques, seuls les chemins d'entrée diffèrent.
Les templates de view ne devraient pas contenir if request.host.include?("how2claude.dev") — laid, pas DRY. Extraire les 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
Ensuite chaque view partagée (articles/_row.html.erb, articles/show.html.erb, breadcrumbs, sidebar) utilise smart_article_path(article, category) au lieu de article_path(...).
smart_category_path retourne dev_root_path sur .dev — parce que .dev n'a pas de page de catégorie séparée (tout le site est une catégorie), les liens de catégorie doivent pointer vers home.
Ces helpers permettent à un seul template de view de se comporter correctement sur les deux domaines sans bifurquer la view.
app/views/layouts/dev.html.erb et application.html.erb sont bifurqués — sombre vs. clair, monospace vs. sans, emerald vs. orange. Mais chaque lien fonctionnel reste aligné avec le layout principal :
<!-- 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>
Le visuel bifurque, mais Pricing / Sign in / Account / switcher de locale — aucun de ces liens fonctionnels ne disparaît. Je me suis fait avoir (ci-dessous).
/:slug a avalé /pricing (commit 23163cc)Les routes dev ressemblaient au départ à ceci :
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
Visiter how2claude.dev/pricing → matche /:slug → Dev::ArticlesController#show → Category.find_by!(slug: "claude-code").articles.find_by!(slug: "pricing") → ActiveRecord::RecordNotFound → 404.
Fix : déclarer /pricing explicitement avant /: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
Les routes Rails matchent dans l'ordre de déclaration — /pricing doit venir avant /:slug pour réclamer le match d'abord.
Piège bonus : l'alias doit être dev_pricing, pas pricing — parce que le bloc .com a déjà as: :pricing. Rails ne lèvera pas d'erreur ; la définition ultérieure écrase silencieusement la première, donc pricing_path sur .com commencerait à générer une URL attachée au bloc .dev. Quand Claude a écrit ça la première fois, il a utilisé as: :pricing ; je ne l'ai attrapé que quand j'ai cliqué sur /pricing sur .com et remarqué que quelque chose était bizarre — la string URL /pricing avait l'air correcte, mais la cible de route avait changé (et comme les deux routes frappaient PagesController#pricing, le bug était silencieux).
Règle : quand la même action est accrochée à plusieurs blocs de route, chaque bloc a besoin d'un as unique.
eac4b2f)La première fois que j'ai fait construire dev.html.erb par Claude, il a écrit une version dépouillée — logo de marque + switcher de locale seulement. Raisonnement : "site dev est un site de contenu, nav épurée."
Envoyé. J'ai enregistré un nouveau compte sur .dev — pas de lien Sign in. J'ai dû taper /session/new à la main. Puis les utilisateurs payants sont venus — pas de lien Pricing. Taper /pricing à la main.
Ce n'est pas un design épuré — ce sont des features manquantes. eac4b2f a copié le core de nav de .com, restylé pour thème sombre.
Règle : les visuels peuvent bifurquer (sombre/clair, monospace/sans) mais la fonctionnalité doit rester alignée. Chaque point d'entrée core qu'un site a (Pricing, Sign in, Account, locale) l'autre en a besoin, sauf raison produit explicite contraire.
Quand tu demandes à Claude de construire un layout bifurqué, dis explicitement : "garde toutes les entrées fonctionnelles, change seulement le visuel." Son défaut est de "simplifier," et côté utilisateur "simplifier" se lit comme "feature manquante."
/accounts n'avait pas de bouton Sign-out (commit 92b34a8)Pas directement lié au multi-domaine, mais surgi à la même période.
Le template de session de Rails 8 génère une route DELETE /session mais n'expose jamais le bouton nulle part. Seule façon pour les utilisateurs de se déconnecter :
curl -X DELETE https://how2claude.com/session -H "Cookie: ..."
Clairement inacceptable. 92b34a8 a ajouté un button_to "Sign out" discret en bas de /accounts avec turbo_confirm pour éviter les clics accidentels.
Règle : quand tu audites la complétude d'une feature, ne trace pas seulement le chemin direct. Signup, login, payer, utiliser — ça Claude le couvre de manière exhaustive. Les chemins inverses (sign out, annuler abonnement, supprimer compte) sont manqués. Faire énumérer à Claude "chaque transition d'état utilisateur" donne une couverture plus large que lui demander "fais la page compte."
Les hosts de production sont how2claude.com et how2claude.dev ; localement il faut simuler les deux. /etc/hosts :
127.0.0.1 how2claude.test
127.0.0.1 dev.how2claude.test
Puis le constraint de route ajoute .test comme alias local pour .dev :
constraints(host: /\A(how2claude\.dev|how2claude\.test)(:\d+)?\z/) do
(Dans ce qui précède, how2claude.test est effectivement l'alias local pour .dev. Le trafic .com local frappe localhost ou 127.0.0.1 par défaut — aucun host constraint ne matche, ça tombe dans le bloc de route par défaut.)
En pratique : bin/rails s -b 127.0.0.1 -p 3000 qui tourne, puis taper how2claude.test:3000 dans le navigateur pour tester les routes .dev, localhost:3000 pour .com. Deux domaines, un processus Rails, changer le code et les deux rechargent.
Laisser Claude faire tourner deux sites de marque depuis une base Rails — checklist complète :
constraints(host:) dans routes.rb, place-le avant le bloc par défaut (Rails matche dans l'ordre de déclaration).as uniques pour éviter la collision./pricing avant les catch-alls /:slug, ou elles sont avalées.smart_article_path / on_dev_domain? gardent les views agnostiques du host. Pas de if request.host.include?(...) à l'intérieur des templates./etc/hosts + domaine .test localement pour simuler le domaine dev. Ajoute .test comme alias dev dans le constraint de route.La difficulté technique de faire tourner deux sites de marque depuis une base Rails n'est pas élevée — constraints(host:) plus les abstractions helper t'y mènent. Ce qui a vraiment besoin de ton attention, c'est la discipline de la bifurcation : qu'est-ce qui est partagé, qu'est-ce qui bifurque, quand bifurquer, quand fusionner. Claude peut écrire le code correctement, mais il ne décidera pas pour toi si les deux sites doivent se ressembler. C'est un jugement produit.