← Back to postsblog แรกของผม

blog แรกของผม

ปกติผมชอบแชร์เรื่องนู้นเรื่องนี้ใน Facebook กับ LinkedIn อยู่บ่อยๆ ทั้งเรื่อง Code เรื่อง Technical เรื่องมีมที่โดนเส้นหรือเรื่องที่อ่านเจอจาก Content ของคนที่ผมติดตามอยู่ พอโพสต์ไปมันก็ค่อยๆ หายไปกับฟีด บางอันก็ไม่ค่อยได้กลับมาอ่านเท่าไหร่ เลยคิดอยู่พักนึงว่าจะทำ SaaS สักตัวมาเก็บโพสต์ที่ชอบแชร์ไว้เป็นสรุปประจำสัปดาห์ให้ตัวเองอ่านดีไหม หรือจะทำแบบไหนดี สุดท้ายก็คิดว่าช่างมันเถอะ อยากเขียนบล็อกด้วย ก็เลยทำบล็อกละกันนน แล้วก็อยากห่างๆ จาก Social อยู่เหมือนกัน วันนี้เลยลงมือทำบล็อกของตัวเองสักที ที่ที่เขียนแล้วมันอยู่ตรงนั้นจริงๆ อยากแชร์ก็ค่อยเอาลิงก์ไปแปะ Social ของเราทีหลัง... แต่ก็ยอมรับว่าผมก็ยังชอบอ่าน Content ที่คนอื่นหรือคนที่ผมชอบโพสต์ใน Social อยู่ดีแหละ ฮ่าๆ

ในเมื่อมาถึงตรงนี้แล้ว ขอจดไว้หน่อยว่าเว็บนี้ทำด้วยอะไร อาจจะ Geekๆ ล้นๆ ไปหน่อยนะครับ ถือเป็นการประทับตราบล็อกแรกไว้ด้วยเลย XD

Stacks ที่ผมเลือกใช้

  • VitePress สำหรับ build โดยเขียน theme เองทั้งหมด ผมไม่ได้ใช้ Default Theme ที่มาพร้อมกับตัว Docs ก็เลยไม่มี Sidebar และไม่มีแถบค้นหาสำเร็จรูป (ทำเองว่าซ่านนน จริงๆ ใช้ Harness + Open Design ช่วยครับ)
  • Vue สำหรับ Component หน้าแรกและหน้าโพสต์เป็นต้น
  • บทความทุกชิ้นเป็นไฟล์ Markdown เพราะสำหรับผมแล้วสะดวกและจัดการง่ายดี (แรกๆ คิดอยู่พักนึงว่าเราจะทำ Backend + Rich Text Editor แล้วเก็บข้อมูลไว้และมี Backoffice ดีไหมนะ แต่...สุดท้ายก็จบท่านี้)
  • Deploy บน Netlify ด้วย สะดวกดี (ฟรี)

Design

ดีไซน์ผมตั้งใจให้เรียบ พื้นหลังสีเข้ม ฟอนต์ monospace แบบเดียว สีเน้นสีเดียว ไม่มีเงา ไม่มี gradient เพราะไปเจอคนที่ผมชอบหลายๆ คนทำสไตล์นี้กันหมดเลยและสบายตาดี

ตัวดีไซน์ที่ผมบอก ผมใช้ Open Design ซึ่งเป็น open source (nexu-io/open-design) เราสามารถใช้ Harness ไม่ว่าจะเป็น Claude Code, Qwen Code, Codex หรือตัวอื่นๆ ที่ทุกคนชอบให้เชื่อม Open Design อยู่แล้วป้อน Prompt บอกหน้าตาที่อยากได้ผ่าน Open Design ได้เลยย เอาแส้ฟาดอยู่พักนึงสู...

รูปแบบที่ได้ทั้งสี ฟอนต์ ระยะห่าง ผมให้มันเขียนกฎพวกนี้ (รวมถึงข้อห้ามพวก AI Slop) ไว้ในไฟล์ DESIGN.md กับ brand.json ทั้งคู่เลย ใครอยากตามดูก็เข้าไปได้

ผมเปิด public ไว้ที่ github.com/joyboy18900/surakiat.com

ผมอยากได้ posts สองภาษาโดยไม่ต้องทำสองชุดขนานกัน

VitePress มีโหมด i18n ในตัวที่เติม prefix ภาษาไว้หน้า URL เช่น /en/ แต่ผมไม่ใช้ เพราะมันมองแต่ละภาษาเป็นเว็บคนละชุด ต้องดูแลรายการบทความสองชุดขนานกัน เขียนไทยหนึ่งชิ้นก็ต้องมีอังกฤษมาคู่เสมอ ไม่งั้นจำนวนบทความที่โชว์มันเพี้ยน

ผมเลยทำแบบนี้แทน บทความหนึ่งชิ้นเป็นไฟล์ไทยไฟล์เดียวที่ posts/ปี/ชื่อ.md เป็นหน้าเดียวที่มีอยู่จริง ชิ้นไหนอยากมีฉบับแปลก็เพิ่มไฟล์ ชื่อ.en.md ในโฟลเดอร์เดียวกัน ไฟล์นั้นไม่กลายเป็นหน้าแยก แต่ถูกดึงมาแสดงเป็นปุ่มสลับ ไทย/English บนหน้าเดิม สลับฝั่ง client ไม่โหลดหน้าใหม่ ไม่มีไฟล์แปลก็ไม่มีปุ่ม

ผลคือรายการบทความมีชุดเดียว ไม่มีอะไรให้ sync ซึ่งในมุมผมมันสบายใจกว่าเยอะ

ผมคำนวณเวลาการอ่านจากจำนวนตัวอักษรยังไง?

หาข้อมูลอยู่สักพัก (เอิ่ม Claude หาให้... XD) สูตรเวลาอ่านทั่วไปคือนับจำนวนคำแล้วหารด้วยความเร็วอ่านต่อนาทีใช้กับภาษาไทยไม่ได้เพราะภาษาไทยไม่เว้นวรรคระหว่างคำ โปรแกรมที่นับคำด้วยการหาช่องว่างจะนับทั้งย่อหน้าเป็นคำเดียว สรุปคือเวลาอ่านที่ได้จะต่ำกว่าความจริงมาก

บล็อกนี้จึงนับจำนวนตัวอักษรของเนื้อหาไทยแทน ส่วนฉบับแปลอังกฤษถ้ามียังนับคำตามปกติ

ts
// ตัดโค้ดบล็อกในบทความออกก่อน ไม่เอามานับเป็นเนื้อหา
function stripCodeFences(src: string): string {
  return src.replace(/```[\s\S]*?```/g, '')
}

const THAI_CHARS_PER_MINUTE = 500

export function estimateThaiReadingTime(src: string): number {
  const text = stripCodeFences(src)
  return Math.max(1, Math.round(text.length / THAI_CHARS_PER_MINUTE))
}

ระบบค้นหาแบบไม่ง้อ tokenized index

ฟีเจอร์นี้ผมคิดอยู่นานว่าจะใช้ Algolia ดีไหมนะ เพราะจริงๆ แค่อยากได้กล่องกด Cmd+K หรือ Ctrl+K แล้วเปิดช่องค้นหาบทความขึ้นมา แล้ว VitePress official เองก็ใช้ Algolia อยู่ เลยเป็นตัวเลือกแรกที่นึกถึง

แต่พอมานั่งคิดจริงๆ Algolia มันเกินไปสำหรับบล็อกที่มีโพสต์ไม่กี่ชิ้น ต้องผูก service ข้างนอก ตั้ง index คอย sync อีก (จริงๆ แล้วผมอยากทำเองมากกว่า ขี้เกียจดึงไลบรารีมาใช้) ส่วน local search ฟรีที่ VitePress มีในตัว เบื้องหลังมันใช้ MiniSearch ที่แยกคำด้วยช่องว่าง เจอภาษาไทยที่ไม่เว้นวรรคก็จบเห่ เหตุผลเดียวกับเรื่องเวลาอ่านเป๊ะ

สุดท้ายเลยทำเอง จับ substring ตรงๆ แล้วให้น้ำหนักเอง เจอในหัวข้อสำคัญกว่าเจอในหมวดหมู่ และเจอในหมวดหมู่สำคัญกว่าเจอในคำอธิบาย

ts
// index ทำครั้งเดียวตอนโหลด lowercase ไว้เลย
const searchIndex = posts.map((p) => ({
  post: p,
  title: p.title.toLowerCase(),
  category: p.category.toLowerCase(),
  description: p.description.toLowerCase(),
}))

const results = computed(() => {
  const q = query.value.trim().toLowerCase()
  if (!q) return []
  return searchIndex
    .map((p) => ({
      post: p.post,
      score:
        (p.title.includes(q) ? 3 : 0) +       // เจอในหัวข้อ
        (p.category.includes(q) ? 2 : 0) +    // เจอในหมวดหมู่
        (p.description.includes(q) ? 1 : 0),  // เจอในคำอธิบาย
    }))
    .filter((r) => r.score > 0)
    .sort((a, b) => b.score - a.score || b.post.date.localeCompare(a.post.date))
    .map((r) => r.post)
})

วิธีที่ผมทำมันโง่ๆ ตรงๆ เลยนะ พิมพ์ผิดนิดเดียวก็หาไม่เจอ ไม่มีลูกเล่นเดาใจแบบ Google (ไม่มี fuzzy ไม่มี typo tolerance อะไรทั้งนั้น) แล้วมันก็ค้นแค่ในหัวข้อ หมวด กับคำโปรย ไม่ได้ไล่อ่านทั้งบทความ

แต่ผมว่ามันพอ บทความมีไม่กี่สิบชิ้น ต่อให้โตเป็นร้อยชิ้น การไล่เช็ก .includes ทีละอันทุกครั้งที่พิมพ์ (ต่อ keystroke) ก็ยังเร็วอยู่ดี พอชั่ง trade-off แล้วมันไม่ต่างกันมาก ที่เสียไปจริงๆ มีแค่ fuzzy แลกกับการไม่ต้องสร้าง index เอง ไม่ต้องดึงไลบรารีของใครมา เขียนเองยี่สิบบรรทัดจบ แถมใช้ได้ทั้งไทยและอังกฤษ เพราะมันไม่ต้องตัดประโยคเป็นคำๆ ตั้งแต่แรก ซึ่งภาษาไทยตัดยากอยู่แล้ว

เมื่อก่อน DefaultTheme จัดให้หมด

ไม่ได้เอา DefaultTheme ของ VitePress มาต่อ ของกระจุกกระจิกที่มันเคยแถมเลยต้องทำเอง อย่างสีในโค้ดบล็อก Shiki ใส่สีไว้ให้แล้วแต่ CSS ที่แปลงเป็นสีจริงอยู่ใน DefaultTheme พอไม่โหลดก็เลยจืด เติมเองไม่กี่บรรทัดก็จบ ส่วน dark/light mode ก็เขียนปุ่มสลับเอง เก็บค่าที่เลือกไว้ใน localStorage แล้วมีสคริปต์เล็กๆ ใน <head> คอย set ธีมก่อนหน้า render เสร็จ ไม่งั้น reload ทีหน้าจอจะวาบดำแป๊บนึง

ยังมีอีกหลายส่วนที่อยากเล่า แต่เอาไว้เท่านี้ก่อน ใครสนใจไปส่องใน repo เอาได้เลย


What's next

บล็อกนี้เป็นพื้นที่ของผมเอง ไม่ได้กะให้เป็นบล็อกเทคอย่างเดียว จะเขียนเรื่องงาน เรื่องที่อ่านเจอ ไปจนถึงบันทึกชีวิตประจำวันปนๆ กันไป แยกด้วย tag ใครอยากอ่านเฉพาะเรื่องเทคก็กรองเอา

เริ่มต้นแค่นี้ก่อน ที่เหลือค่อยว่ากันครับ บะบุยยยย

I often share things on Facebook and LinkedIn. Code, technical stuff, memes that feel too real, or things I read from people I follow. But after I post, it slowly gets lost in the feed. I don't come back to read most of it again. So for a while I thought about building a small SaaS to save my favorite posts and turn them into a weekly summary for myself. Or maybe some other way. In the end I thought, never mind. I also wanted to write a blog. So I just made a blog instead. I also want some distance from social media. So today I finally built my own blog. A place where what I write actually stays. When I want to share something, I can paste the link on my social media later... But I admit I still like reading what other people, and people I like, post on social media. Haha.

Since I have come this far, let me write down what this site is built with. It may be a bit too geeky. Think of it as a stamp on my first blog post. XD

The stack I chose

  • VitePress for the build. I wrote the whole theme myself. I don't use the Default Theme that comes with the docs, so there is no sidebar and no ready-made search bar. (Look at me doing it all myself. Really, I used a Harness plus Open Design to help.)
  • Vue for components like the home page and the post page.
  • Every post is a Markdown file, because that is easy for me to write and manage. (At first I thought for a while about building a backend plus a rich text editor, storing the data, and having a back office. But... this is where it ended.)
  • I deploy on Netlify too. It is convenient. (Free.)

Design

I wanted the design to be plain. A dark background, one monospace font, one accent color, no shadows, no gradients. Many people I like use this style, and it is easy on the eyes.

For that design, I use Open Design, which is open source (nexu-io/open-design). You can connect a Harness to it - Claude Code, Qwen Code, Codex, or whatever you like. Then you give Open Design a prompt that describes the look you want. I kept pushing it hard for a while, and...

For the result - the colors, the fonts, the spacing - I asked it to write these rules (including what to avoid, like AI slop) in both DESIGN.md and brand.json. You can go and look if you want.

I made it public at github.com/joyboy18900/surakiat.com

I want bilingual posts without keeping two parallel copies

VitePress has a built-in i18n mode. It adds a language prefix to the URL, like /en/. But I don't use it. It treats each language as a separate site. You have to keep two parallel post lists. Every Thai post always needs an English one next to it. If not, the post count shown goes wrong.

So I do this instead. Each post is one Thai file at posts/year/name.md. That is the only real page. If a post needs a translation, I add a name.en.md file in the same folder. That file does not become its own page. Instead it shows up as a Thai/English toggle on the same page. The switch happens on the client, with no page reload. No translation file means no toggle.

The result: there is only one post list. There is nothing to sync. For me that feels much better.

How do I calculate reading time from the character count?

I looked into this for a while (well, Claude looked it up for me... XD). The common reading-time formula counts the number of words and divides by a reading speed per minute. It does not work for Thai. Thai does not put spaces between words. A program that counts words by looking for spaces will count a whole paragraph as one word. So the reading time comes out much lower than the truth.

So this blog counts the number of characters in the Thai content instead. The English translation, if there is one, still counts words as usual.

ts
// remove code blocks from the post first, don't count them as content
function stripCodeFences(src: string): string {
  return src.replace(/```[\s\S]*?```/g, '')
}

const THAI_CHARS_PER_MINUTE = 500

export function estimateThaiReadingTime(src: string): number {
  const text = stripCodeFences(src)
  return Math.max(1, Math.round(text.length / THAI_CHARS_PER_MINUTE))
}

A search that does not rely on a tokenized index

For this feature I thought for a long time about whether to use Algolia. I really just wanted a box that opens with Cmd+K or Ctrl+K to search posts. The official VitePress site uses Algolia too. So it was the first option I thought of.

But when I really thought about it, Algolia is too much for a blog with only a few posts. You have to connect an outside service, set up an index, and keep it in sync. (Honestly I would rather build it myself. I am too lazy to pull in a library.) The free local search built into VitePress uses MiniSearch behind the scenes. It splits words by spaces. With Thai, which has no spaces, it falls apart. Same reason as the reading-time problem.

In the end I built it myself. I match on the substring directly and set my own weights. A match in the title matters more than a match in the category. A match in the category matters more than a match in the description.

ts
// build the index once on load, lowercase it right away
const searchIndex = posts.map((p) => ({
  post: p,
  title: p.title.toLowerCase(),
  category: p.category.toLowerCase(),
  description: p.description.toLowerCase(),
}))

const results = computed(() => {
  const q = query.value.trim().toLowerCase()
  if (!q) return []
  return searchIndex
    .map((p) => ({
      post: p.post,
      score:
        (p.title.includes(q) ? 3 : 0) +       // match in title
        (p.category.includes(q) ? 2 : 0) +    // match in category
        (p.description.includes(q) ? 1 : 0),  // match in description
    }))
    .filter((r) => r.score > 0)
    .sort((a, b) => b.score - a.score || b.post.date.localeCompare(a.post.date))
    .map((r) => r.post)
})

My way is dumb and simple. One small typo and it finds nothing. It has no clever guessing like Google (no fuzzy match, no typo tolerance at all). And it only searches the title, the category, and the short description. It does not read through the whole post.

But I think it is enough. There are only a few dozen posts. Even if it grows to a hundred, running .includes one by one on every keystroke is still fast. When I weigh the trade-off, the difference is small. What I really give up is fuzzy match. In return, I do not build an index myself and I do not pull in anyone's library. It is twenty lines of my own code. It also works for both Thai and English, because it never needs to cut sentences into words in the first place. And Thai is hard to cut anyway.

DefaultTheme used to handle all of this

I did not build on top of VitePress's DefaultTheme. So the small things it used to give me, I had to do myself. For example, the colors in code blocks: Shiki adds the colors, but the CSS that turns them into real colors lives in DefaultTheme. Without it loaded, the colors look flat. A few lines of my own fixed it. For dark and light mode, I wrote the toggle myself. It saves the choice in localStorage. A small script in the <head> sets the theme before the page finishes rendering. If not, the screen flashes dark for a moment on every reload.

There are still many parts I want to talk about. But I will stop here for now. If you are interested, go and look in the repo.


What's next

This blog is my own space. I do not plan it to be only a tech blog. I will write about work, things I read, and daily life notes, all mixed together. I split them with tags. If you only want to read tech, just filter.

That is all for the start. The rest, we will see later. Byeee.