Free

ให้ Claude รันสองเว็บไซต์แบรนด์จากโค้ด Rails ชุดเดียว (how2claude.com + how2claude.dev)

สองแบรนด์ หนึ่งแอป Rails — host constraints ใน routes.rb + namespace dev + smart helper + สามกับดักจริง (catch-all slug, nav เคลื่อน, ขาด sign-out)


how2claude มีสองเว็บไซต์:

  • how2claude.com — เว็บทั่วไป ครอบคลุมหมวดหมู่การใช้งาน Claude ทั้งหมด (getting-started / prompting / use-cases / tools / comparisons / claude-code)
  • how2claude.dev — เน้นนักพัฒนา แสดงเฉพาะหมวดหมู่ claude-code แบรนด์แยก URL แบน ธีมเทอร์มินัลมืด

95% ของเนื้อหาแชร์กัน (ข้อมูลบทความเดียวกัน) 20% ของการนำเสนอต้องแยกสาย (แบรนด์ nav โครงสร้าง URL ธีม) สอง repo Rails ชัดเจนว่าไม่คุ้ม — ระบบบทความ การจ่ายเงิน บัญชี OAuth i18n บำรุงรักษาเป็นสองเท่า แต่ โค้ดชุดเดียว สองโดเมน มีขอบต้องวาดเพียบ: route แยกยังไง controller reuse มั้ย view รู้ได้ไงว่ารันอยู่บนโดเมนไหน layout แยกที่ไหน

ผมให้ Claude สร้างเรื่องนี้ ต่อไปนี้คือบันทึกฉบับสมบูรณ์ว่าเส้นแยกอยู่ตรงไหน — งาน feature เมื่อ 5 วันก่อน ตอนนี้สองเว็บทั้งคู่อยู่บน production แล้ว


คำถามหลัก: อะไรแชร์ อะไรแยก

แมปแชร์ vs. แยกตั้งแต่แรก:

แชร์ (โค้ดเดียว สองโดเมนใช้):
- โมเดลบทความ Category Series
- login OAuth สมาชิก Stripe จ่าย x402
- หน้าบัญชี /accounts (ฟีเจอร์เดียวกัน UI เดียวกัน)
- หน้าราคา /pricing (ข้อมูลเดียวกัน control Stimulus เดียวกัน)
- กลไก locale i18n ไฟล์ภาษา 19 ไฟล์

แยก (ต่างกันระหว่างโดเมน):
- หน้าแรก: .com เรียกดูหลายหมวดหมู่ vs. .dev ดีไซน์เฉพาะสำหรับนักพัฒนา (hero $ claude --master)
- โครงสร้าง URL: .com /:category/:slug vs. .dev /:slug (แบน)
- เนื้อหาที่แสดง: .dev แสดงเฉพาะบทความหมวด claude-code
- ธีม: .com พื้นขาวพร้อมจุดเด่น orange vs. .dev มืด monospace emerald
- Navigation: .com มีลิงก์หมวดหมู่ vs. .dev ลดรูปเหลือ Pricing + Sign in

หลักการหลัก: อย่าแตะชั้นข้อมูล แยก controller ที่ไหนที่จำเป็น ทำ view / helper ให้รู้จัก host

สถาปัตยกรรม: บล็อก host-constraint ใน routes.rb

การแยกทั้งหมดไหลมาจาก 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

สองหมายเหตุ:

  1. regex ของ host ต้องแม่นยำ — ยึดคำนำหน้า how2claude\.dev ด้วย \A match port ไม่บังคับด้วย (:\d+)? how2claude.test คือโดเมน dev ท้องถิ่น (ผ่าน dnsmasq หรือ /etc/hosts ชี้ไป 127.0.0.1) เพิ่มใน pattern ทำให้ route dev ทำงานในท้องถิ่นด้วย
  2. scope locale ซ้อนอยู่ใน host constraint — บล็อก route ทั้งสองห่อ scope "(:locale)" คำนำหน้า URL เช่น /zh, /ja ทำงานบนทั้งสองเว็บโดยไม่ต้องเขียนใหม่

PagesController#pricing ตัวเดียวกันถูกเชื่อมในทั้งสองบล็อก — method controller หนึ่ง route helper ต่างกัน: .com ใช้ pricing_path .dev ใช้ dev_pricing_path กลลวง alias นี้เลี่ยง "การชนชื่อ helper" (สองโดเมนตั้งชื่อ helper สำหรับ /pricing เป็น pricing จะทำให้นิยามหลังทับนิยามแรก)

controller namespace dev: เฉพาะเมื่อจำเป็นต้องแยก

บอก 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 ของ paywall แชร์ระหว่างสองเว็บ — กฎจ่ายเงินเหมือนกัน แค่ path เข้าต่างกัน

helper ฉลาด: ให้ view ไม่รู้จัก host

template 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, breadcrumb, sidebar) ใช้ smart_article_path(article, category) แทน article_path(...)

smart_category_path คืน dev_root_path บน .dev — เพราะ .dev ไม่มีหน้าหมวดหมู่แยก (ทั้งเว็บเป็นหมวดหมู่เดียว) ลิงก์หมวดหมู่ควรชี้ไปหน้าแรก

helper ชุดนี้ทำให้ template view เดียวทำงานถูกต้องบนสองโดเมน โดยไม่ต้องแยก view

การแยก layout: ธีม + nav แต่ต้องมีวินัย

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>

visual แยก แต่ Pricing / Sign in / Account / switcher locale — ไม่มีลิงก์ฟังก์ชันไหนหายไป โดนอันนี้ (ข้างล่าง)

กับดัก #1: catch-all /:slug กลืน /pricing (commit 23163cc)

route 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 → match /:slugDev::ArticlesController#showCategory.find_by!(slug: "claude-code").articles.find_by!(slug: "pricing")ActiveRecord::RecordNotFound → 404

แก้: ประกาศ /pricing อย่างชัดเจนก่อน /: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

route Rails match ตามลำดับการประกาศ/pricing ต้องมาก่อน /:slug เพื่อแย่ง match ก่อน

กับดักโบนัส: alias ต้องเป็น dev_pricing ไม่ใช่ pricing — เพราะบล็อก .com มี as: :pricing อยู่แล้ว Rails ไม่โยน error; นิยามหลังทับนิยามแรกเงียบ ๆ ดังนั้น pricing_path บน .com จะเริ่ม generate URL ผูกกับบล็อก .dev ตอน Claude เขียนอันนี้ครั้งแรกใช้ as: :pricing; จับได้ตอนคลิก /pricing บน .com แล้วรู้สึกว่ามีอะไรผิด — string URL /pricing ดูถูก แต่เป้าหมาย route เปลี่ยน (และเพราะสอง route ตี PagesController#pricing bug เงียบ)

กฎ: เมื่อ action เดียวกันต่อเข้ากับหลายบล็อก route แต่ละบล็อกต้องมีชื่อ as เฉพาะ

กับดัก #2: nav dev ขาด Pricing + Sign in (commit eac4b2f)

ครั้งแรกที่ให้ Claude สร้าง dev.html.erb เขาเขียนเวอร์ชันรีดลง — แค่โลโก้แบรนด์ + switcher locale เหตุผล: "เว็บ dev เป็นเว็บเนื้อหา nav สะอาด"

ส่ง สมัครบัญชีใหม่บน .dev — ไม่มีลิงก์ Sign in ต้องพิมพ์ /session/new มือ แล้วผู้ใช้จ่ายเงินมา — ไม่มีลิงก์ Pricing พิมพ์ /pricing มือ

นั่นไม่ใช่ดีไซน์สะอาด — นั่นคือฟีเจอร์หาย eac4b2f คัดลอก core nav จาก .com restyle เป็นธีมมืด

กฎ: visual แยกได้ (มืด/สว่าง monospace/sans) แต่ฟังก์ชันต้องสอดคล้อง จุดเข้า core ทุกจุดที่เว็บหนึ่งมี (Pricing, Sign in, Account, locale) อีกเว็บต้องการ เว้นแต่มีเหตุผลผลิตภัณฑ์ชัดเจนไม่ให้มี

เมื่อขอ Claude สร้าง layout แยก บอกชัด: "รักษาจุดเข้าเชิงฟังก์ชันทั้งหมด เปลี่ยนแค่ visual" default ของเขาคือ "ลดรูป" และฝั่งผู้ใช้อ่าน "ลดรูป" เป็น "ฟีเจอร์หาย"

กับดัก #3: /accounts ไม่มีปุ่ม Sign-out (commit 92b34a8)

ไม่เกี่ยวกับ multi-domain โดยตรง แต่โผล่ในช่วงเดียวกัน

template session ของ Rails 8 generate route DELETE /session แต่ไม่แสดงปุ่มที่ไหนเลย ทางเดียวที่ผู้ใช้จะออกจากระบบ:

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

ไม่ยอมรับได้ชัด ๆ 92b34a8 เพิ่ม button_to "Sign out" แบบ muted ที่ด้านล่าง /accounts พร้อม turbo_confirm ป้องกันคลิกโดยไม่ตั้งใจ

กฎ: เมื่อ audit ความสมบูรณ์ของฟีเจอร์ อย่าตาม path ไปข้างหน้าอย่างเดียว สมัคร login จ่าย ใช้ — Claude ครอบคลุมครบถ้วน path ย้อนกลับ (sign out ยกเลิกสมาชิก ลบบัญชี) มักถูกพลาด ให้ Claude enumerate "ทุก transition state ของผู้ใช้" ให้ coverage กว้างกว่าการขอ "ทำหน้าบัญชี"

การพัฒนาท้องถิ่น

host production คือ how2claude.com กับ how2claude.dev ในท้องถิ่นต้องจำลองทั้งคู่ /etc/hosts:

127.0.0.1 how2claude.test
127.0.0.1 dev.how2claude.test

แล้ว constraint route เพิ่ม .test เป็น alias ท้องถิ่นสำหรับ .dev:

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

(ข้างบน how2claude.test คือ alias ท้องถิ่นสำหรับ .dev จริง ๆ traffic .com ท้องถิ่นตี localhost หรือ 127.0.0.1 ค่าเริ่มต้น — ไม่มี host constraint match ตกลงในบล็อก route ค่าเริ่มต้น)

ทางปฏิบัติ: bin/rails s -b 127.0.0.1 -p 3000 รันอยู่ แล้วตี how2claude.test:3000 ในบราวเซอร์เพื่อทดสอบ route .dev localhost:3000 สำหรับ .com สองโดเมน process Rails เดียว แก้โค้ดแล้วทั้งคู่ reload

Checklist

ให้ Claude รันสองเว็บไซต์แบรนด์จากโค้ด Rails ชุดเดียว — checklist เต็ม:

  1. แมปแชร์ vs. แยกก่อน — module ไหน match ไหนไม่ อย่าเปิด namespace ตั้งแต่ต้น
  2. ห่อบล็อก dev ด้วย constraints(host:) ใน routes.rb วาง ก่อน บล็อกค่าเริ่มต้น (Rails match ตามลำดับการประกาศ)
  3. controller namespace dev เฉพาะเมื่อพฤติกรรมแยก controller ที่แชร์ได้ (PagesController, AccountsController) ต่อเข้ากับทั้งสองบล็อก route ด้วยชื่อ as เฉพาะเลี่ยงชน
  4. ประกาศหน้าเฉพาะเช่น /pricing อย่างชัดเจนก่อน catch-all /:slug ไม่งั้นโดนกลืน
  5. helper smart_article_path / on_dev_domain? ทำให้ view ไม่รู้ host ไม่มี if request.host.include?(...) ใน template
  6. แยก visual ใน layout แต่รักษาจุดเข้าฟังก์ชันให้สอดคล้อง บอก Claude "รักษาฟังก์ชันทั้งหมด เปลี่ยนแค่ visual" — default คือลดรูป และอ่านเป็นสูญเสียฟีเจอร์
  7. audit path ย้อนกลับแยกต่างหาก sign out ยกเลิก ลบ มักถูกพลาด; ขอ Claude enumerate "ทุก transition state ของผู้ใช้" ครอบคลุมมากกว่าขอ "ทำทุกหน้า"
  8. ใช้ /etc/hosts + โดเมน .test ในท้องถิ่น เพื่อจำลองโดเมน dev เพิ่ม .test เป็น alias dev ใน constraint route

ความยากเชิงเทคนิคของการรันสองเว็บไซต์แบรนด์จากโค้ด Rails ชุดเดียวไม่สูง — constraints(host:) บวกกับนามธรรม helper พาคุณไปถึง สิ่งที่ต้องการความสนใจของคุณจริง ๆ คือ วินัยของการแยก: อะไรแชร์ อะไรแยก เมื่อไรแยก เมื่อไรรวม Claude เขียนโค้ดถูกได้ แต่เขาจะไม่ตัดสินใจแทนคุณว่าสองเว็บควรดูเหมือนกันหรือไม่ นั่นเป็นวิจารณญาณด้านผลิตภัณฑ์