บทความ › เว็บและแอป

สี่เว็บจาก เอนจิน เดียว แชร์โค้ดด้วย Astro monorepo แต่หน้าตาไม่เหมือนกัน

แยก core/ กับโฟลเดอร์ต่อเว็บ ให้สี่เว็บใช้ layout i18n และ deploy ชุดเดียว ต่างกันแค่สี ฟอนต์ และหน้าแรก ผ่าน design tokens พร้อมบั๊กจริงที่แก้ครั้งเดียวได้ทั้งสี่เว็บ

กลาง อ่าน 15 นาที 21 ตุลาคม 2569

อ่านจบแล้วจะรู้
  • ก๊อปเว็บไปสี่ชุดคือสร้างที่ต้องแก้สี่ที่ แยกส่วนที่เหมือนกันไว้ใน 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.tslayout หัวเว็บ ท้ายเว็บ เมนูมือถือ
สีและฟอนต์ทุกตัว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

core/ เครื่องยนต์ร่วมlayout · บทความ · i18n · base.css (ไม่มีสี)aroysureส้ม/ขาวpickoshopชมพู/ขาวdevmakerxมืดmykasetเขียว/ขาวแต่ละเว็บ: site.ts · tokens.css · taxonomy · Home
โค้ดอยู่ที่เดียว สีและข้อมูลอยู่ที่เว็บ

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
  1. ย้าย src/layouts src/components src/lib และ CSS โครงไป core/ ไล่ลบรหัสสีและชื่อฟอนต์ใน CSS ให้เป็น var(--…) ทั้งหมด
  2. เขียน core/astro-config.mjs ส่งออก siteConfig({ site, dir }) ตั้ง alias @core @site และ server.fs.allow
  3. ใน site-a/ สร้าง astro.config.mjs สองบรรทัด src/site.ts src/styles/tokens.css และ src/pages/index.astro ที่เรียก layout จาก @core
  4. npx astro dev --root site-a เปิดดู แล้วก๊อป site-a เป็น site-b เปลี่ยนแค่ site.ts กับ tokens.css
  5. แก้อะไรสักอย่างใน core/ แล้วเปิดทั้งสองเว็บ ถ้าได้พร้อมกันทั้งคู่ โครงนี้ใช้ได้

ข้อผิดพลาดที่เจอบ่อย

  • เหลือรหัสสีหรือชื่อฟอนต์ค้างใน CSS ร่วม เว็บใหม่จะมีสีของเว็บเก่าโผล่มาจุดเดียว หายาก
  • ตั้งชื่อ token ตามสี (--pink) แทนบทบาท (--sun) พอเว็บถัดไปเป็นสีเขียว ชื่อก็หลอกคนอ่าน
  • แก้ “ทรง” ใน tokens.css จนกลายเป็นแก้โครง ถ้า override เกินสิบกว่าบรรทัด ควรกลับไปถามว่า core ควรรองรับแบบไหน
  • ลืม server.fs.allow Vite จะปฏิเสธการอ่านไฟล์ใน 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 ให้ใช้ตรง ๆ

คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ

อ่านต่อ

#เว็บกลาง

CSS anchor positioning ใช้ได้ทุก browser แล้ว: tooltip และเมนู ไม่ต้องเขียน JS

anchor positioning เป็น Baseline ตั้งแต่ Firefox 147 ผูก tooltip หรือเมนูกับปุ่มด้วย CSS ล้วน หลบขอบจอเองด้วย position-try-fallbacks พร้อมทางสำรองสำหรับ browser เก่า

อ่าน 9 นาที
#เว็บกลาง

ให้ AI ตรวจหน้าเว็บบน มือถือ เอง ด้วย สคริปต์ 30 บรรทัด แทนการนั่งไล่ดูทุกหน้า

AI มองจอไม่เห็น แต่วัดได้ สคริปต์เล็ก ๆ โหลดทุกหน้าใน iframe กว้าง 375px แล้วบอกว่าอะไรล้นจอ หัวข้อไหนตกขอบ ตัวหนังสือใน SVG ชนกรอบตรงไหน ใช้จริงก่อน deploy ทุกครั้ง

อ่าน 12 นาที
#เว็บกลาง

ภาษาไทยบนเว็บ ตัดคำ ฟอนต์ เวลาอ่าน อะไรพัง และแก้ยังไงใน 5 เว็บ

เบราว์เซอร์ตัดคำไทยด้วยพจนานุกรม พลาดคำทับศัพท์ในหัวข้อใหญ่ keep-all ไม่ช่วย วิธีแก้ที่ใช้จริง เวลาอ่านนับตัวอักษร คู่ฟอนต์ไทย+อังกฤษ line-height และ lang

อ่าน 10 นาที