Skip to content

อัพเดท Stack หลังบ้านเว็บ 2024

Published
 at 01:20 PM

By : Parinya T. | 4 min read

Table Of Contents

Show all topics

TL:DR; (ยาวไปไม่อ่าน)

สวัสดีครับผู้อ่านทุกท่าน หลังจากที่หายหน้าหายตาไปนาน วันนี้ผมกลับมาพร้อมกับการรื้อเว็บนี้อีกครั้งครับ เหตุเกิดจากช่วงนี้มีงานเว็บเข้ามา เป็นงานช่วงเช้า ซึ่งเปลี่ยนพฤๅติกรรมของผมจากคนนอนดึก ให้ต้องปรับเวลานอน เพื่อที่จะตื่นเช้ามาเตรียมตัวเผื่อมีอะไรให้ทำ ส่งผลให้ช่วงบ่ายผมพอจะมีเวลาว่างอยู่บ้าง กอปรกับความฟุ้งซ่านจากในอดีตที่เคยใช้ Notion ทำเป็นตัวจัดการเนื้อหา (Content management system : CMS)

ในตอนนั้นยังไม่มี Official API ผมเลยอาศัยโปรเจ็กต์ที่มีคนเขียนอยู่แล้วมาใช้งาน ตอนนั้นเป็น Notion + NextJs ซึ่งก็ดูใช้ได้ดีไม่ติดขัดอะไรในช่วงแรง

แต่เมื่อเวลาผ่านไปสักระยะ ทาง Notion ได้ปล่อย Official API ออกมา และจำกัดการเข้าถึงของ API ตัวเดิม ส่งผลให้เว็บของผมได้รับผลกระทบไปด้วย เนื่องจากตัวเว็บไซต์ของผมทำงานในเชิง Server Side คือ เมื่อมีการเรียกหน้าเว็บไซต์จะทำการดึงข้อมูลเนื้อหาจาก Notion ผ่าน API มาทำการแสดงผลเป็นรายครั้ง

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

ปัญหาที่เกิดขึ้นทำให้ผมตัดสินใจย้ายไปใช้ tech stack ตัวอื่นๆ ก่อนจะกลับมาเป็นเว็บโฉมปัจจุบันนี้ และกลับมาใช้วิธีการ build แบบ static site และเขียนเนื้อหาเป็นไฟล์ markdown แทน แต่ใช้วิธีแยกเนื้อหา กับ เฟรมเวิร์ค ออกเป็นคนละส่วนแทน (รายละเอียดส่วนดังกล่าว)

วันเวลาผ่านไปหนึ่งปี กอปรกับเป็นคนขี้เบื่อ จึงเริ่มคิดถึงการใช้ระบบจัดการเนื้อหา เพราะสะดวกกับการเขียนมากกว่าการมานั่งเขียนไฟล์ markdown เอง ทำให้คิดถึงการใช้ Notion ขึ้นมา และจำได้ว่าสามารถ export เนื้อหาออกไปเป็นไฟล์ markdown ได้ จึงลองหาข้อมูลว่ามีใครทำวิธีคล้ายๆกันนี้บ้าง เพื่อจะมาเป็นแนวทาง

เข้าสู่เนื้อหาหลัก

หาข้อมูลและวิธีอยู่หลายเดือน จนบังเอิญไปเจอกับโปรเจ็กต์ตัวหนึ่งที่ถูกเขียนไว้ [Link] ซึ่งตรงกับสิ่งที่กำลังมองหาทุกอย่าง ไม่ว่าจะเป็น

ของดีย่อมมีข้อจำกัด

จากข้อจำกัดในการทำเว็บไซต์แบบ Server-side ผมเคยพบเจอปัญหาในการ Fetch ข้อมูลมาแสดงผลแล้วมาบ้าง ไม่มาบ้าง ที่เป็นข้อจำกัดเดิม จนทำให้ผมเปลี่ยนวิธีมาใช้แบบ โหลดข้อมูลที่ต้องใช้ทุกอย่างมา Build หน้าเว็บทั้งหมดเป็นรายครั้ง เพื่อแก้ปัญหาจุดเดิม

ข้อจำกัดของวิธีนี้ คือ ข้อมูลที่แสดงผลบนหน้าเว็บฯ จะไม่ถูกอัพเดทตามข้อมูลหลังบ้าน (บน Notion) จนกว่าจะมีการสั่ง Build หน้าเว็บใหม่ในครั้งต่อไป

กล่าวโดยสรุปคือ เราต้องทำการสั่ง Build เว็บทั้งหมดใหม่ทุกครั้งที่มีการเพิ่ม ลบ หรือแก้ไขบทความ

ซึ่งทางผู้พัฒนา api ได้แนะนำวิธีการว่าให้ใช้เว็บฟรีในการสั่งคำสั่ง build เมื่อมีการอัพเดตเนื้อหาบน Notion แต่ผมรู้สึกว่า เว็บเหล่านั้นจำนวนครั้งที่ให้ใช้ฟรีต่อเดือนค่อนข้างน้อยไปหน่อย แต่ไม่เป็นอะไร ขอให้ api ตัวนี้ใช้ได้จริงก่อน อย่างอื่นเดี๋ยวเขียนเพิ่มทีหลังได้ (เราก็ไปทางเก่งซะแล้ว ฮ่าๆ)

เริ่มงาน

เพื่อป้องกันความสับสน ผมจะค่อยๆเรียงลำดับสิ่งที่จำเป็นต้องทำทีละขั้นตอนตามลำดับการทำงานเวลาเรียกใช้งาน โดยเริ่มจาก

1. ติดตั้ง Dependencies ที่จำเป็น

ทางผู้พัฒนาได้แนะนำให้ติดตั้ง Dependencies เพิ่มหลายตัว แต่เมื่อพิจารณาถึงส่วนที่จำเป็นจริงๆ มีที่ต้องใช้เพียง

"@notionhq/client"
"dotenv"
"image-type"
"notion-to-md"
"sharp"
"tinycolor2"

สามารถติดตั้งทั้งหมดได้เลย

bun installl -D @notionhq/client dotenv image-type notion-to-md sharp tinycolor2

จากนั้น ผมจะยังไม่เข้าไปยุ่งในส่วนของ script บนไฟล์ package.json เพราะยังไม่มีส่วนที่ต้องใช้งาน (เขียนไปก็ยังรันไม่ได้อยู่ดี)

2. เพิ่มไฟล์ที่จำเป็น

จากต้นฉบับ https://github.com/jsonMartin/AstroNot ไฟล์ที่ผมพิจารณาว่าจำเป็นต่อการใช้งานกับเว็บตัวเอง จะเหลือเพียงเท่านี้

ซึ่งในต้นฉบับจะเป็น

switch (ext) {
    case ".webp":
      return await import(`../images/posts/${name}.webp`);
    case ".jpg":
      return await import(`../images/posts/${name}.jpg`);
    case ".png":
      return await import(`../images/posts/${name}.png`);
    case ".svg":
      return await import(`../images/posts/${name}.svg`);
    case ".gif":
      return await import(`../images/posts/${name}.gif`);
    case ".avif":
      return await import(`../images/posts/${name}.avif`);
    case ".jpeg":
      return await import(`../images/posts/${name}.jpeg`);
    case ".bmp":
      return await import(`../images/posts/${name}.bmp`);
    default:
      return await import(`../images/posts/${name}.jpg`);
  }

ทำการแก้ไข path ปลายทางเป็น

switch (ext) {
    case ".webp":
      return await import(`../assets/images/blog/${name}.webp`);
    case ".jpg":
      return await import(`../assets/images/blog/${name}.jpg`);
    case ".png":
      return await import(`../assets/images/blog/${name}.png`);
    case ".svg":
      return await import(`../assets/images/blog/${name}.svg`);
    case ".gif":
      return await import(`../assets/images/blog/${name}.gif`);
    case ".avif":
      return await import(`../assets/images/blog/${name}.avif`);
    case ".jpeg":
      return await import(`../assets/images/blog/${name}.jpeg`);
    case ".bmp":
      return await import(`../assets/images/blog/${name}.bmp`);
    default:
      return await import(`../assets/images/blog/${name}.jpg`);
  }

สาเหตุก็เพราะ ผมเปลี่ยนมาใช้การเก็บไฟล์รูปในโฟลเดอร์ assets ตามที่ Astro แนะนำ


จากนั้นทำการสร้างไฟล์ src/libs/notion.js ซึ่ง path ตามต้นฉบับไม่ใช่ที่นี่ (File structure ก็ไม่ใช่แบบนี้ ผมก็ไม่เข้าใจตัวเองเหมือนกันว่ามันทำไมไปเก็บ libs ไว้ใน src ฮ่าๆ)

ไฟล์ต้นฉบับชื่อ astronot.js

โดยไฟล์ notion.js นี้จะเป็นไฟล์หลักที่ถูกเรียกใช้งานเพื่อดึงข้อมูลจาก Notion มาแปลงเป็นไฟล์เนื้อหาโพสต์ในเว็บบล็อก ผมได้ทำการแก้ไขหลายจุดเพื่อให้เป็นไปตาม frontmatter ของเว็บนี้

//const POSTS_PATH = `src/pages/posts`; // path เดิม
const POSTS_PATH = `src/content/blog`; // path ใหม่
//return `<Image src="/images/posts/${fileName}" />`; // path เดิม
return `<Image src="@assets/images/blog/${fileName}" />`; // path ใหม่

และในส่วน Create Page นั้นผมได้ปรับเปลี่ยนเกือบทั้งหมด เพื่อให้เป็นไปตาม fronmatter ของเว็บ

// Create Pages
const pages = results.map(page => {
  const { properties, cover, created_time, last_edited_time, archived } = page;
  const title = properties.title.title[0].plain_text;
  const slug = properties?.slug?.rich_text[0]?.plain_text || sanitizeUrl(title);

  console.info("Notion Page:", page);

  return {
    id: page.id,
    title,
    type: page.object,
    cover: cover?.external?.url || cover?.file?.url,
    tags: properties.tags.multi_select,
    created_time,
    last_edited_time,
    featured: properties?.featured?.select?.name,
    archived,
    status: properties?.status?.select?.name,
    publish_date: properties?.publish_date?.date?.start,
    modified_date: properties?.modified_date?.date?.start,
    description: properties?.description?.rich_text[0]?.plain_text,
    slug,
  };
});

for (let page of pages) {
  console.info(
    "Fetching from Notion & Converting to Markdown: ",
    `${page.title} [${page.id}]`
  );
  const mdblocks = await n2m.pageToMarkdown(page.id);
  const { parent: mdString } = n2m.toMarkdownString(mdblocks);

  const estimatedReadingTime = readingTime(mdString || "").text;

  // Download Cover Image
  const coverFileName = page.cover
    ? await downloadImage(page.cover, { isCover: true })
    : "";
  if (coverFileName) console.info("Cover image downloaded:", coverFileName);

  // Generate page contents (frontmatter, MDX imports, + converted Notion markdown)
  const pageContents = `---
title: "${page.title}"
slug: "${page.slug}"
ogImage: ${coverFileName}
featured: ${page.featured === "featured" ? true : false}
tags: ${JSON.stringify(page.tags.map(tag => tag.name))}
draft: ${page.status === "draft" ? true : false}
pubDatetime: ${page.publish_date === undefined ? page.created_time : page.publish_date}
modDatetime: ${page.modified_date === undefined ? page.publish_date : page.modified_date}
description: "${page.description === "undefined" ? "" : page.description}"
readingTime: "${estimatedReadingTime}"
---
import Image from '../../components/Image.astro';

${mdString}
`;

  if (mdString)
    fs.writeFileSync(
      `${process.cwd()}/${POSTS_PATH}/${page.slug}.mdx`,
      pageContents
    );
  else console.log(`No content for page ${page.id}`);

  console.debug(`Sleeping for ${THROTTLE_DURATION} ms...\n`);
  await delay(THROTTLE_DURATION); // Need to throttle requests to avoid rate limiting
}

3. เพิ่ม Script ใน package.json

หลังจากสร้างไฟล์ที่จำเป็นครบแล้ว คราวนี้เรากลับมาที่ไฟล์ package.json เพื่อทำการเพิ่มสคริปต์ จะได้ง่ายต่อการสั่งงาน โดยคำสั่งที่ต้องเพิ่ม คือ

"scripts": {
	//...other...
	//"sync": "astro sync", คำสั่งเดิมลบออกได้เลย แทบจะไม่ได้ใช้
	"sync": "rimraf src/content/blog/_ && node src/libs/notion.js",
	"sync:published": "rimraf src/content/blog/_ && node src//libs/notion.js --published",
	"generate": "rimraf dist/*_ && ([ -d 'dist' ] || mkdir dist) && ([ -d 'dist/images' ] || mkdir dist/images) && ([ -d 'src/content/blog' ] || mkdir src/content/blog) && ([ -d 'src/assets/images' ] || mkdir src/assets/images) && ([ -d 'src/assets/images/blog' ] || mkdir src/assets/images/blog) && rimraf src/content/blog/_ && node src/libs/notion.js --published && astro build && jampack ./dist",
},

4. สร้าง Notion Database ที่จะเก็บโพสต์

ทำการสมัครสมาชิก และล็อกอิน Notion ให้เรียบร้อย จากนั้นจะสามารถคัดลอก Template นี้ไปใช้ได้

Template url : [Notion]

ไม่ควรเปลี่ยนชื่อหัวตาราง เพราะเมื่อรันคำสั่งจะทำให้เรียกข้อมูลไม่ได้

หรือจะเปลี่ยนก็ได้ แต่ไปแก้ไขไฟล์ notion.js ให้ตรงกันด้วย เวลาเรียกข้อมูลจะได้ไม่มีปัญหาทีหลัง

5. สร้าง Integration บน Notion เพื่อดึงข้อมูล

Blog Image Blog Image Blog Image

6. สร้างไฟล์ environment

ทำการสร้างไฟล์ .env.local (ใช้ dev บน local) โดยมี parameter ดังนี้

NOTION_KEY='secret_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
DATABASE_ID='XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'

โดย NOTION_KEY คือ secret ที่เราได้จากการสร้าง integration เมื่อสักครู่

DATABASE_ID สามารถหาได้โดย copy ลิงค์หน้า notion database ของเรา โดย จะอยู่ในรูปแบบ

www.notion.so/pickyzz/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX?v=zzzzzzzzzzzzz

จากนั้นนำไปวางในไฟล์ .env และ save ให้เรียบร้อย

7. ทดสอบ sync ข้อมูล

เมื่อได้ส่วนที่จำเป็นครบหมดแล้ว ก็มาถึงช่วงเวลาที่สำคัญคือการทดสอบว่าทั้งหมดที่ทำมา มันใช้งานได้จริงหรือไม่ โดย

bun run sync

ถ้า log ออกมาประมาณนี้ ถือว่าผ่านแล้ว

Blog Image

ตรวจสอบ path ของไฟล์ markdown และ ไฟล์ภาพ ว่าถูกต้องตามตำแหน่งที่ควรจะเป็นหรือไม่

Blog Image

ครบถ้วนสมบูรณ์ดี จากนั้นทดลองเปิด local dev ดูว่าหน้าเว็บขึ้นตามปกติไหม

Blog Image

ถือว่าผ่าน (จริงๆผมทดสอบโดยการ run test แต่กลัวจะไม่เห็นภาพ)

8. ทำการย้ายเนื้อหาทั้งหมดไปยัง Notion

เป็นขั้นตอนที่กินเวลายาวนานที่สุดแล้ว แต่ข้อดีของ Notion คือ เราสามารถ copy ไฟล์ภาพจากในเครื่อง แล้วไปวางได้เลย ตัวแอพจะอัพโหลดให้ทันที

แต่มีข้อควรระวังเรื่องการ copy จากหน้าเว็บ เพราะจะเป็นการวาง url ของภาพแทน ถ้าต้นทางลบรูปภาพข้อมูลภาพในโพสต์หายไปด้วย และรวมถึงเวลารันคำสั่ง syn จะโหลดเป็นนามสกุล .undefied ทำให้ไม่สามารถ build ได้

สรุป

ในส่วนของการแก้ไข ci/cd เพิ่มเติม ผมขออนุญาติยกยอดไปไว้โพสต์ต่อไป เนื่องจากโพสต์นี้ก็ยาวพอสมควรแล้ว

ขอขอบคุณผู้อ่านที่อ่านจนถึงตรงนี้ครับ 🙏🏻

References