两个品牌站共用一套 Rails 代码——routes.rb host constraints + dev namespace + 智能 helper + 三个真实踩坑(catch-all 吞路由、导航走样、漏 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 那套都要维护两份。但一套代码跑两个域名有一堆边界要划:路由怎么分、controller 要不要复用、view 怎么感知自己在哪个域名上跑、layout 分叉在哪。
让 Claude 把这事做完了。本文是分叉边界的完整实录——5 天前的 feature 工作,现在两个站都在生产跑。
先把共享和分叉列出来:
共享(一份代码,两个域名都用):
- 文章数据模型、Category、Series
- OAuth 登录、Stripe 订阅、x402 支付
- 账户页 /accounts(同样的功能,同样的 UI)
- 定价页 /pricing(同样的数据,同样的 Stimulus 控件)
- i18n locale 机制、19 种语言文件
分叉(两个域名不一样):
- 首页:.com 多分类浏览 vs .dev 开发者专属设计($ claude --master hero)
- URL 结构:.com /:category/:slug vs .dev /:slug(扁平)
- 可见内容:.dev 只显示 claude-code 分类的文章
- 主题:.com 白底 orange 点缀 vs .dev 深色 monospace emerald
- 导航:.com 有分类链接 vs .dev 简化只有 Pricing + Sign in
核心原则:数据层不动,controller 层按需分叉,view / helper 层做宿主感知。
所有分叉的入口是 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 helper 被调用:.com 走 pricing_path,.dev 走 dev_pricing_path。这个 alias 技巧避免了 "helper 名称冲突"(两个域名的 /pricing 同名 helper 会被 Rails 后定义覆盖前定义)。
一上来让 Claude 做这件事,它想给每个页面都建 Dev::XxxController。不对——大多数 controller 两个域名共用。只有行为不同时才需要 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,两个站复用——付费规则一致,只是入口路径不同。
view 模板不能有 if request.host.include?("how2claude.dev") 这种判断——丑、不 DRY。抽 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
然后所有共享 view(articles/_row.html.erb、articles/show.html.erb、面包屑、侧边栏)都用 smart_article_path(article, category) 代替 article_path(...)。
smart_category_path 在 .dev 域名下返回 dev_root_path——因为 .dev 没有单独的分类页(整个站就一个 category),分类链接应该指向首页。
这套 helper 让一个 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 catch-all 吞了 /pricing(commit 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。
修复:在 /:slug 之前显式写 /pricing:
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 前面才能抢先命中。
还有一个 bonus 坑:alias 必须叫 dev_pricing,不能叫 pricing——因为 .com 块里已经有 as: :pricing 了。Rails 不会报错,只会后面定义的覆盖前面的,造成 .com 下 pricing_path 生成出 .dev 版本的 URL。让 Claude 写这段的时候它第一次写了 as: :pricing,我切回 .com 点 /pricing 才发现哪里不对——pricing_path 生成的 URL 是 /pricing 没错,但指向的 controller 逻辑变了(没看出区别是因为是同一个 PagesController#pricing)。
规律:同一个 action 挂到多个路由块里,each 块要用唯一的 as 名。
eac4b2f)我让 Claude 做 dev.html.erb 时它写了简化版——只有品牌 logo + locale 切换器。理由:"dev 站是内容站,导航要干净"。
上线后我在 .dev 上注册新账户——发现没有 Sign in 链接。只能手敲 /session/new。然后付费用户进来——没 Pricing 链接。只能手敲 /pricing。
这不是设计简洁,这是漏功能。commit eac4b2f 把 .com 的核心导航抄过来,style 调成深色主题。
规律:视觉可以分叉(深色/浅色、monospace/sans),但功能必须对齐。一个站有的核心入口(Pricing、Sign in、Account、locale),另一个也要有,除非有明确的产品理由不让。
让 Claude 做分叉 layout 时,要主动说 "保留所有功能入口,只改视觉"。它默认会"简化",但"简化"在用户端就是功能缺失。
/accounts 没有 Sign out 按钮(commit 92b34a8)这个跟多域名没直接关系,但是在同期发现的。
Rails 8 的 session 模板生成了 DELETE /session 路由但没在任何地方暴露出按钮。用户登出的唯一方式是:
curl -X DELETE https://how2claude.com/session -H "Cookie: ..."
显然不行。commit 92b34a8 在 /accounts 底部加了一个 muted 的 "Sign out" button_to,带 turbo_confirm 防误触。
规律:做 feature 完整性 audit 时不要只看正向 path。注册、登录、付费、使用——这些 Claude 会盖全。反向 path(登出、取消订阅、删除账户)容易漏。让 Claude 列一遍"用户的每个状态转换"比让它"做账户页"得到的覆盖更全。
生产域名是 how2claude.com 和 how2claude.dev,本地需要模拟两个域名。/etc/hosts:
127.0.0.1 how2claude.test
127.0.0.1 dev.how2claude.test
然后路由 constraint 加上 .test 作为 dev 域名的本地别名:
constraints(host: /\A(how2claude\.dev|how2claude\.test)(:\d+)?\z/) do
(上面例子里 how2claude.test 其实是作为 .dev 本地别名。.com 本地走默认的 localhost 或 127.0.0.1——没有 host 约束所以会落到默认路由块。)
实际开发:bin/rails s -b 127.0.0.1 -p 3000 跑起来,浏览器里用 how2claude.test:3000 测 .dev 路由,用 localhost:3000 测 .com 路由。两个域名同一个 Rails 进程,改代码两边都 reload。
让 Claude 给一套 Rails 跑两个品牌站的完整清单:
routes.rb 用 constraints(host:) 包 dev 块,放在默认块之前(Rails 按声明顺序匹配)。/pricing 这种要在 /:slug catch-all 前显式声明,否则被吞。smart_article_path / on_dev_domain? helper 让 view 不感知宿主。不要在 view 里写 if request.host.include?(...)。/etc/hosts + .test 域名 模拟 dev 域名。路由 constraint 加 .test 作为 dev 别名。一套 Rails 代码跑两个品牌站的技术难度不高——Rails 的 constraints(host:) + helper 抽象就够了。真正需要你盯的是分叉的纪律:哪些共享、哪些分叉、何时分叉、何时合并。Claude 可以把代码写对,但它不会替你想"这两个站到底要不要长得一样"。那是你的产品判断。