สี่เว็บจาก เอนจิน เดียว แชร์โค้ดด้วย Astro monorepo แต่หน้าตาไม่เหมือนกัน
แยก core/ กับโฟลเดอร์ต่อเว็บ ให้สี่เว็บใช้ layout i18n และ deploy ชุดเดียว ต่างกันแค่สี ฟอนต์ และหน้าแรก ผ่าน design tokens พร้อมบั๊กจริงที่แก้ครั้งเดียวได้ทั้งสี่เว็บ
- ก๊อปเว็บไปสี่ชุดคือสร้างที่ต้องแก้สี่ที่ แยกส่วนที่เหมือนกันไว้ใน core แล้วให้แต่ละเว็บถือเฉพาะสิ่งที่ทำให้มันเป็นตัวเอง
- CSS ร่วมต้องไม่มีสีและฟอนต์เลย ทุกสีเป็นตัวแปรใน tokens.css ของเว็บนั้น เว็บถึงจะต่างกันได้โดยไม่ต้องแก้ core
- แก้บั๊กใน core ครั้งเดียว build ใหม่ แล้วทุกเว็บได้พร้อมกัน แต่ก็แปลว่าทุกการแก้ core ต้องตั้งใจ
- เว็บที่ต้องการ feature ต่างกันจริง ๆ ไม่ควรยัดเข้า engine เดียว
เว็บความรู้แต่ละเว็บของเรามีหน้าบทความ สารบัญ เส้นทางเรียน สองภาษา และ SEO แบบเดียวกันหมด ต่างกันแค่ชื่อ สี ฟอนต์ และหน้าแรก พอต้องทำเว็บที่สอง สาม สี่ คำถามคือจะก๊อปเว็บแรกไปทั้งก้อน หรือจะแยกส่วนที่เหมือนกันออกมาเป็นเอนจินแล้วให้แต่ละเว็บถือเฉพาะส่วนที่เป็นของตัวเอง บทความนี้เล่าแบบที่สอง ซึ่งเอนจินดึงออกมาจากเว็บ 6amdev.com นี้เอง แล้วเอาไปรัน aroysure.com, pickoshop.com, devmakerx.com และ mykaset.com
ทดสอบกับ Astro 5.14 บน Node 22 ฝั่ง build และ Ubuntu 24.04 กับ nginx ฝั่งเซิร์ฟเวอร์
ปัญหา: ก๊อปเว็บสี่รอบ = แก้สี่ที่
ก๊อปโฟลเดอร์เว็บแล้วเปลี่ยนสีกับชื่อ วันแรกเสร็จเร็วมาก แต่ตั้งแต่สัปดาห์ที่สอง ทุกบั๊กที่เจอในหน้าบทความ ทุก header ความปลอดภัยที่เพิ่ม ทุกการปรับ layout บนมือถือ ต้องทำซ้ำสี่ที่ และต้องจำให้ได้ว่าทำครบหรือยัง เว็บที่สี่จะค่อย ๆ ต่างจากเว็บแรกโดยไม่มีใครตั้งใจ
สิ่งที่เหมือนกันควรมีที่เดียว สิ่งที่ต่างกันควรอยู่ในไฟล์ไม่กี่ไฟล์ที่ชี้ได้ว่าอยู่ตรงไหน
แยก core กับ site
โฟลเดอร์เดียว sites/ มี package.json และ node_modules ชุดเดียว ข้างในแยกเป็น core กับหนึ่งโฟลเดอร์ต่อเว็บ
sites/
├── core/ # เอนจิน: layouts, components, lib, base.css
├── _starter/ # แม่แบบ ก๊อปเมื่อจะเพิ่มเว็บ
├── aroysure/ pickoshop/ devmakerx/ mykaset/
│ ├── astro.config.mjs
│ ├── src/site.ts # ชื่อ URL tagline nav โลโก้ ช่องทางติดต่อ
│ ├── src/styles/tokens.css # สีและฟอนต์ทั้งหมดของเว็บนี้
│ ├── src/data/taxonomy.json # หมวด ระดับ เส้นทางเรียน
│ ├── src/components/Home.astro # หน้าแรกของตัวเอง
│ ├── src/content/blog/{th,en}/ # บทความ
│ └── public/
└── scripts/ # run.mjs deploy.sh nginx-site.sh nginx-site.conf
แต่ละเว็บเป็น Astro root ของตัวเอง astro.config.mjs ของเว็บมีแค่สองบรรทัด
import { siteConfig } from '../core/astro-config.mjs';
export default siteConfig({ site: 'https://aroysure.com', dir: import.meta.url });
ส่วน core/astro-config.mjs ตั้ง i18n sitemap และ alias สองตัวที่ทำให้ทั้งระบบเดินได้ @core ชี้ไปโฟลเดอร์ core และ @site ชี้ไป src/ ของเว็บที่กำลัง build
vite: {
resolve: { alias: { '@core': fileURLToPath(new URL('.', import.meta.url)), '@site': here('./src') } },
server: { fs: { allow: [here('..')] } },
},
layout กลางจึง import { SITE } from '@site/site' และ import '@site/styles/tokens.css' ได้โดยไม่รู้ว่าตัวเองกำลังเป็นเว็บไหน ส่วน fs.allow จำเป็นเพราะ Vite ไม่ยอมอ่านไฟล์นอก root ของเว็บถ้าไม่บอก
build ทีละเว็บด้วย astro build --root <site> สคริปต์ scripts/run.mjs แค่หาโฟลเดอร์ที่มี astro.config.mjs แล้ววนรัน
const sites = readdirSync('.').filter((d) => existsSync(`${d}/astro.config.mjs`) && !d.startsWith('_'));
if (cmd === 'build-all') for (const s of sites) run(`npx astro build --root ${s}`);
npm run dev -- aroysure 4331 # เว็บเดียว
npm run build:all # ทั้งสี่
สิ่งที่แต่ละเว็บถือเอง กับสิ่งที่ได้มาจาก core
| เว็บถือเอง | ไฟล์ | ได้จาก core |
|---|---|---|
| ชื่อ URL tagline คำอธิบาย nav โลโก้ ช่องทางติดต่อ | src/site.ts | layout หัวเว็บ ท้ายเว็บ เมนูมือถือ |
| สีและฟอนต์ทุกตัว | src/styles/tokens.css | หน้าบทความ สารบัญ FAQ บทความที่เกี่ยวข้อง |
| หมวด ระดับ เส้นทางเรียน | src/data/taxonomy.json | หน้าหมวด หน้าเส้นทางเรียน บันทึกทดลอง |
| หน้าแรก | src/components/Home.astro | สลับภาษา TH/EN hreflang |
| บทความ รูป ไอคอน | src/content/, public/ | ตัวกรอง SEO JSON-LD sitemap หน้า 404 |
Home.astro เป็นข้อยกเว้นที่ตั้งใจ หน้าแรกคือหน้าที่เว็บต้องต่างกันจริง จึงให้แต่ละเว็บเขียนเอง (มี home.css ของตัวเองได้) ส่วนที่เหลือ src/pages/** เป็นไฟล์บาง ๆ ที่เรียก component จาก @core
tokens: สีทุกสีเป็นตัวแปร
หัวใจของการ “แชร์โค้ดแต่หน้าตาต่าง” อยู่ที่ core/src/base.css บรรทัดแรกของไฟล์เขียนไว้ชัด
/* Shared structure for every site. No colours or fonts here: those are tokens in each site's
src/styles/tokens.css (--bg --surface --soft --line --ink --muted --sun --on-sun --violet --violet-soft
--night --night-2 --on-night --on-night-muted --term --display --body --mono). */
base.css กว่า 600 บรรทัดไม่มีรหัสสีสักตัว ทุกจุดอ้าง var(--sun) var(--ink) ฯลฯ ชื่อตัวแปรติดมาจาก 6amdev ที่ธีมคือพระอาทิตย์ขึ้น (--sun = สีปุ่มหลัก, --night = แถบหัวเว็บ) ชื่อไม่ตรงความหมายในเว็บอื่นแต่เราไม่เปลี่ยน เพราะชื่อที่สื่อ “บทบาท” สำคัญกว่าชื่อที่สื่อสี ไฟล์ base.css เดียวกันจึงออกมาเป็น
| เว็บ | --bg | --ink | --sun (ปุ่ม) | --on-sun | --violet (ลิงก์) | --night (หัวเว็บ) |
|---|---|---|---|---|---|---|
| pickoshop | #FFFFFF | #3B1527 | #C2185B | #FFFFFF | #A3175F | #FFFFFF |
| mykaset | #FFFFFF | #16301C | #2E7D32 | #FFFFFF | #2E6B30 | #FFFFFF |
| aroysure | #FFFFFF | #2A1712 | #FF8A1F | #2A1712 | #A8380B | #FF8A1F |
| devmakerx | #F3F6F1 | #10201A | #C8F03C | #10201A | #1D5BB8 | #0C2A20 |
ชมพู/ขาว เขียว/ขาว ส้ม/ขาว และธีมเมกเกอร์โทนเข้ม จาก HTML และ CSS โครงเดียวกัน devmakerx มีธีมมืดด้วย โดยประกาศตัวแปรชุดเดิมซ้ำใน @media (prefers-color-scheme:dark) component ไม่ต้องรู้เรื่องนี้เลย
tokens.css แก้รูปทรงเพิ่มได้ แต่จำกัดเฉพาะ “ทรง” ไม่ใช่โครง เช่น pickoshop ใช้ปุ่มทรงแคปซูล
.btn,.lang-switch,.fchip{border-radius:999px}
mykaset ใช้การ์ดขอบซ้ายเขียว
.svc,.steps li{border-left:5px solid var(--sun)}
aroysure เป็นเว็บเดียวที่หัวเว็บเป็นสี เพราะ --night ของมันคือส้มเดียวกับ --sun ลิงก์ที่ hover ใน core ใช้สี --sun จะหายไปในหัวเว็บ จึง override ให้ขีดเส้นใต้แทน
.main-nav a:hover,.main-nav a[aria-current="page"]{color:var(--on-night);text-decoration:underline}
กฎที่ทุก tokens.css ต้องผ่าน คือ ข้อความกับพื้นหลังทุกคู่ตัดกันอย่างน้อย 4.5:1 ตาม WCAG วิธีคิดคือแปลงแต่ละสีเป็น relative luminance (ค่า sRGB แต่ละช่องหารด้วย 255 ถ้าไม่เกิน 0.03928 หารด้วย 12.92 ถ้าเกินใช้ ((c+0.055)/1.055)^2.4 แล้วถ่วงน้ำหนัก 0.2126R + 0.7152G + 0.0722B) แล้วเทียบ (สว่าง+0.05)/(มืด+0.05) เป็นสคริปต์สั้น ๆ รันด้วย Node
const lum = (hex) => { const [r, g, b] = hex.match(/\w\w/g).map((h) => { const c = parseInt(h, 16) / 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; };
const ratio = (a, b) => { const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x); return ((hi + 0.05) / (lo + 0.05)).toFixed(1); };
console.log(ratio('#2A1712', '#FF8A1F')); // 7.2 ตัวหนังสือเข้มบนปุ่มส้มของ aroysure
console.log(ratio('#FFFFFF', '#2E7D32')); // 5.1 ตัวขาวบนปุ่มเขียวของ mykaset
คู่ที่ตรวจแล้วเขียนไว้ใน comment หัวไฟล์ tokens.css ของแต่ละเว็บ เช่น pickoshop: white on sun 5.9 · violet on white 7.4 · sun on violet-soft 4.9 คนที่มาแก้สีทีหลังจะรู้ว่าต้องเช็กคู่ไหน ตัวอย่างที่ชัด คือตัวหนังสือขาวบนส้ม #FFA62B ได้แค่ 2.0 ไม่ผ่าน จึงต้องเป็นตัวหนังสือเข้มบนปุ่มส้มเสมอ
แก้ครั้งเดียว ได้สี่เว็บ
หัวข้อภาษาไทยมีปัญหาเรื่องการตัดคำ เบราว์เซอร์ตัดคำทับศัพท์อย่าง “คลาวด์” กลางคำ วิธีแก้ใน core คือห่อแต่ละวลีด้วย <span class="nw"> ที่เป็น white-space:nowrap ให้ขึ้นบรรทัดใหม่ได้เฉพาะระหว่างวลี
วลีภาษาอังกฤษใน hero ใช้ .nw ตัวเดียวกัน พอเว็บหนึ่งใช้ฟอนต์หัวข้อตัวกว้าง วลีอังกฤษวลีเดียวยาวกว่าจอมือถือ แล้วล้นออกขวาเพราะห้ามตัด ภาษาไทยไม่เป็น เพราะวลีสั้นกว่า
ถ้าเป็นเว็บที่ก๊อปกันมาสี่ชุด ก็ต้องตามไปแก้สี่ที่ และอาจเจอทีละเว็บเมื่อเวลาผ่านไป แต่ตรงนี้แก้ที่ core/src/base.css สองบรรทัด
.nw{white-space:nowrap}
/* English has spaces to break at, and a wide display face can make one phrase wider than a phone */
@media (max-width:520px){html[lang="en"] .nw{white-space:normal}}
ภาษาอังกฤษมีช่องว่างให้ตัดอยู่แล้ว บนจอแคบกว่า 520px จึงปล่อยให้ตัดตามปกติ ส่วนภาษาไทยยังคง nowrap npm run build:all แล้วทั้งสี่เว็บได้แก้พร้อมกัน
อีกด้านของเหรียญ ทุกการแก้ใน core กระทบทุกเว็บ เราจึงเขียนไว้ใน CLAUDE.md ของ repo ว่า core เปลี่ยนได้เฉพาะเมื่อตั้งใจ และหลังแก้ต้องเปิดดูอย่างน้อยสองเว็บที่หน้าตาต่างกันสุด ที่ 375px ทั้งสองภาษา
deploy เว็บเดียวต่อครั้ง
วิธีสลับโฟลเดอร์แบบเดียวกับที่เล่าไว้ใน บทความ atomic deploy แต่สคริปต์รับชื่อเว็บเป็นพารามิเตอร์ และอ่าน domain จาก astro.config.mjs ของเว็บนั้น
npm run deploy -- mykaset
npx astro build --root "$site"
tar -C "$site/dist" -czf "/tmp/$site-dist.tgz" .
# อัปโหลด แล้วบนเซิร์ฟเวอร์:
# แตกไฟล์ลง /tmp/$site-new → chown → mv /var/www/$site เป็น .old → mv ของใหม่เข้าที่ → curl ดูว่าได้ 200
เทียบกับสคริปต์ของ 6amdev มีจุดที่ดีขึ้นหนึ่งอย่าง chown ทำก่อนสลับ ของใหม่จึงพร้อมตั้งแต่วินาทีแรก ย้อนกลับคือสลับ /var/www/<site> กับ <site>.old
ฝั่ง nginx เครื่องนี้มีหลายเว็บใช้ nginx ตัวเดียว ไฟล์ตั้งค่าต่อ domain จึงสร้างจากแม่แบบ scripts/nginx-site.conf ที่มี __NAMES__ กับ __SITE__ ให้ sed แทนค่า แล้วรัน nginx -t ก่อน reload เสมอ ถ้าไม่ผ่านสคริปต์ลบ site ที่เพิ่งเพิ่มออกเองเพื่อไม่ให้เว็บอื่นล่ม
if ! sudo nginx -t; then
echo 'nginx -t failed: removing the new site so other sites keep working'
sudo rm -f /etc/nginx/sites-enabled/$domain
exit 1
fi
sudo systemctl reload nginx
ต้นทุน build เว็บหนึ่ง 21 หน้า (TH+EN) Astro รายงาน 21 page(s) built in 2.75s ทั้งสี่เว็บไม่ถึงครึ่งนาที
เมื่อไหร่ไม่ควรทำแบบนี้
- เว็บที่ต้องการ feature ต่างกันจริง เช่น เว็บหนึ่งต้องมีตะกร้าสินค้า อีกเว็บต้องมี login ยัดเข้า core จะได้ engine ที่เต็มไปด้วย
if (site === ...)แยก repo ดีกว่า - เว็บที่คนดูแลต่างทีม ต่างรอบปล่อย การแก้ core ทีเดียวกระทบทุกคนกลายเป็นข้อเสีย ควรทำ engine เป็น package ที่แต่ละทีมเลือก version เอง
- เว็บเดียว ไม่ต้องแยก core รอให้มีเว็บที่สองจริง ๆ แล้วค่อยดึงออก (เราก็ทำแบบนั้น engine นี้เกิดตอนมีเว็บที่สอง)
ลองใน 10 นาที
mkdir sites && cd sites && npm init -y && npm i astro @astrojs/sitemap
mkdir core site-a site-b
- ย้าย
src/layoutssrc/componentssrc/libและ CSS โครงไปcore/ไล่ลบรหัสสีและชื่อฟอนต์ใน CSS ให้เป็นvar(--…)ทั้งหมด - เขียน
core/astro-config.mjsส่งออกsiteConfig({ site, dir })ตั้ง alias@core@siteและserver.fs.allow - ใน
site-a/สร้างastro.config.mjsสองบรรทัดsrc/site.tssrc/styles/tokens.cssและsrc/pages/index.astroที่เรียก layout จาก@core npx astro dev --root site-aเปิดดู แล้วก๊อปsite-aเป็นsite-bเปลี่ยนแค่site.tsกับtokens.css- แก้อะไรสักอย่างใน
core/แล้วเปิดทั้งสองเว็บ ถ้าได้พร้อมกันทั้งคู่ โครงนี้ใช้ได้
ข้อผิดพลาดที่เจอบ่อย
- เหลือรหัสสีหรือชื่อฟอนต์ค้างใน CSS ร่วม เว็บใหม่จะมีสีของเว็บเก่าโผล่มาจุดเดียว หายาก
- ตั้งชื่อ token ตามสี (
--pink) แทนบทบาท (--sun) พอเว็บถัดไปเป็นสีเขียว ชื่อก็หลอกคนอ่าน - แก้ “ทรง” ใน tokens.css จนกลายเป็นแก้โครง ถ้า override เกินสิบกว่าบรรทัด ควรกลับไปถามว่า core ควรรองรับแบบไหน
- ลืม
server.fs.allowVite จะปฏิเสธการอ่านไฟล์ในcore/ตอน dev - แก้ core แล้ว deploy เว็บเดียว อีกสามเว็บยังเป็นรุ่นเก่าโดยไม่รู้ตัว
คำถามที่พบบ่อย
ทำไมไม่แยกแต่ละเว็บเป็น repo แล้วติดตั้ง engine เป็น npm package
ทำได้ และเหมาะเมื่อมีคนดูแลหลายทีม แต่สำหรับคนเดียวดูแลสี่เว็บ การเปิด pull request ปล่อย version แล้วอัปเดตทีละ repo คืองานเพิ่มที่ไม่ได้อะไรกลับมา โฟลเดอร์เดียว node_modules เดียว แก้แล้วเห็นผลทันทีง่ายกว่ามาก
ธีมมืดทำยังไงเมื่อสีทั้งหมดเป็นตัวแปร
ประกาศตัวแปรชุดเดิมซ้ำใน @media (prefers-color-scheme:dark) ของ tokens.css เว็บนั้น component ใน core ไม่ต้องรู้เลยว่ามีธีมมืด ตอนนี้มีเว็บเดียวที่ทำ (devmakerx) อีกสามเว็บเจ้าของเลือกให้สว่างอย่างเดียว
ใช้วิธีนี้กับเว็บที่ทำด้วย Next.js หรือ framework อื่นได้ไหม
หลักการเดียวกัน แยก layout กับ component ร่วม ให้แต่ละเว็บส่ง config กับ tokens เข้าไป แต่รายละเอียดต่างกัน บทความนี้ยึดตามที่ทำจริงกับ Astro ซึ่งมี --root และ alias ให้ใช้ตรง ๆ
คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ