兩個品牌站共用一套 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 可以把程式寫對,但它不會替你想「這兩個站到底要不要長得一樣」。那是你的產品判斷。