วิธีสร้าง personal website ของตัวเองด้วย Next.js Tailwind และ Contentlayer

วิธีสร้าง personal website ของตัวเองด้วย Next.js Tailwind และ Contentlayer

บทความนี้จะเล่าถึง tech stack และเทคนิคต่าง ๆ ที่ใช้ในการสร้างเว็บไซต์ส่วนตัวครับ ซึ่งผมจะเล่าแบบคร่าว ๆ

สารบัญ

เกริ่นนํา

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

โดยหลัก ๆ เราจะใช้ Language, Framework, Library ประมาณนี้

  1. NextJS + Typescript สำหรับทำเว็บไซต์
NextJS Logo Typescript Logo
  1. ContentLayer สำหรับการแปลง Markdown File ให้เป็นหน้าเว็บไซต์ ซึ่งในส่วนนี้เราจะเขียนบทความในรูปแบบของ Markdown (.md) file แล้วใช้ ContentLayer ในการจัดการนะครับ
Content Layer Logo
  1. และ deploy ตัวเว็บของเราบน Vercel
vercel homepage

ในบทความนี้จะ ไม่ได้ลง เนื้อหาวิธีการ

  • จดโดเมน
  • ผูก DNS กับ CloudFlare
  • React
  • NextJS

นะครับ โดยจะทำเป็นบทความอื่น ๆ ต่อไป

สร้าง NextJS project

ก่อนอื่นเลยเรามาเริ่มจากการสร้าง NextJS project ขึ้นมากันก่อน โดยวิธีที่ง่ายที่สุดคือการทำตาม Document ได้เลยครับ

มาทำไปพร้อม ๆ กันเลย

  1. Run คำสั่งเพื่อ scaffold NextJS
npx create-next-app@latest

scaffold คือการสร้างโครงสร้างพื้นฐานของ Application หรือพูดง่าย ๆ ภาษาชาวบ้านก็คือ วางโครง นั้นเอง ตัวอย่างเช่น t3 stack ที่เป็นการ scaffold NextJS + Prisma (database orm) + Tailwind + Auth + tRPC ครบจบในคำสั่งเดียว

create next app
  1. เบื้องต้นเพื่อไม่ให้งงเลือกตามนี้ไปก่อนนะครับ
  • ใช้ TypeScript
  • ไม่ใช้ Eslint
  • ใช้ Tailwind
  • ใช้ App Router

Eslint คือ ตัวเช็ค coding standard ไว้ผมเขียนอีกบทความอธิบายแล้วกัน น่าจะมีรายละเอียดเยอะพอสมควร

Tailwind คือ CSS framework ที่มีหลักการว่าจะเตรียม css class ต่าง ๆ ไว้ให้เรา โดยเราสามารถเรียกใช้ class เหล่านั้นได้ทันทีโดยที่ไม่ต้องไปประกาศ class ใหม่ ทำให้ตัว HTML และ Mark up อยู่ที่เดียวกัน ซึ่งทำให้ manage ได้ง่ายกว่า เช่น mt-4 จะหมายถึง margin-top: 1rem; เป็นต้น

App Router คือ paradigm ในการเขียน NextJs เลย โดยตัวฟีเจอร์นี้ถูกเพิ่มเข้ามาใน NextJS version 13 ซึ่งจาก Official Document แนะนำว่า app ใหม่ให้ใช้ app router ไปเลย ส่วน app เก่าที่ใช้ page (directory) ก็ใช้ไปก่อน แล้วค่อย ๆ ย้ายมานะจ้ะ

ประมาณนี้

next project config

เมื่อลง dependencies ต่าง ๆ เสร็จแล้ว เปิด directory my-blog ด้วย VS Code ก็จะเจอกับหน้าตาประมาณนี้

vscode after setup
  1. แล้วก็ Run ตัวเว็บขึ้นมาด้วยคำสั่ง
npm run dev

รอสักพักแล้วเปิด Web Browser http://localhost:3000 ก็จะเห็นหน้านี้

NextJS default page

เป็นอันจบพิธี Setup NextJS project ครับ ณ จุดนี้เราจะได้ NextJS Application ที่เป็นแบบ lean ๆ clean ๆ ไม่มีอะไรเลย ในขั้นต่อไปเราจะมาลง Library ที่จำเป็นกันครับ

Setup Contentlayer

ในขั้นนี้เราจะติดตั้ง package ที่ชื่อว่า Contentlayer กันครับ ซึ่งตัว Contentlayer คือ content preprocessor ที่จะ validates และ transforms ตัว Markdown (ทั้ง MD และ MDX) ให้เป็น type-safe JSON และเราก็สามารถเอาสิ่งที่ได้ไปแสดงผลในหน้าเว็บเก๋ ๆ เลย เพื่อให้เข้าใจมากขึ้น เดี๋ยวเรามาลองไปพร้อม ๆ กันครับ

ติดตั้ง Contentlayer

  1. ใน Terminal ของ NextJS ที่เราพึ่งเลือกเมื่อกี้ ให้ run คำสั่งด้านล่างเพื่อติดตั้ง contentlayer และ next-contentlayer
npm install contentlayer next-contentlayer
  1. ที่ root directory ของ project แก้ไขไฟล์ next.config.js

จาก

/** @type {import('next').NextConfig} */
const nextConfig = {};
 
module.exports = nextConfig;

เป็น

const { withContentlayer } = require('next-contentlayer');
 
/** @type {import('next').NextConfig} */
const nextConfig = {};
 
module.exports = withContentlayer(nextConfig);
  1. ที่ root directory ของ project แก้ไขไฟล์ tsconfig.json

จาก

{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

เป็น

{
  "compilerOptions": {
    "baseUrl": ".",
    "target": "es5",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": ["./src/*"],
      "contentlayer/generated": ["./.contentlayer/generated"]
    }
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    ".next/types/**/*.ts",
    ".contentlayer/generated"
  ],
  "exclude": ["node_modules"]
}
  1. เพิ่ม .contentlayer เข้าไปใน .gitignore
.
.
.
#contentlayer
.contentlayer

สร้าง Schema ของ Content

อันนี้จะเป็นการออกแบบโครงสร้างของ Content เรา (ต่อจากนี้ผมจะเรียก Content ว่า บทความ นะครับ) ซึ่งในบทความเราอาจจะบอกว่ามันจะมี Title, วันที่เขียน, หรือ Description ของบทความ หรือข้อมูลอื่น ๆ เป็นต้น ท่านผู้อ่านสามารถดูข้อมูลเพิ่มเติมได้ที่ Define Content Schema หรือทำตามไปพร้อม ๆ กันก็ได้ครับ

  1. สร้างไฟล์ contentlayer.config.ts ที่ root ของ project directory

  2. ใน contentlayer.config.ts เขียน code ด้านล่างนี้ลงไปครับ

import { defineDocumentType, makeSource } from 'contentlayer/source-files';
 
export const Article = defineDocumentType(() => ({
  name: 'Article', // บทความของเราเรียกว่า Article นะ
  filePathPattern: `**/*.mdx`, // กวาดทุก .mdx files
  contentType: 'mdx', // บอกว่าเป็น mdx นะ
  fields: {
    // declare fields และ type ที่จะมีในบทความ
    title: { type: 'string', required: true },
    description: { type: 'string' },
  },
  computedFields: {
    // declare fields พิเศษที่จะเกิดขึ้นตอนกำลัง process บทความ
    url: {
      type: 'string',
      resolve: (article) => `/articles/${article._raw.flattenedPath}`,
    },
  },
}));
 
export default makeSource({
  contentDirPath: 'articles', // มองหาบทความใน directory articles
  documentTypes: [Article],
});

หลาย ๆ ท่านอาจจะคุ้นเคยกับ md file มากกว่า เช่น README.md แต่ในที่นี้เราจะเขียนบทความเป็น mdx file กัน ซึ่ง mdx นี้เราสามารถเรียก React Component ใน markdown ได้เลย แต่ในบทความนี้จะยังไม่ได้พาทำนะครับ

computedFields ดูเพิ่มเติม computedFields เป็นตัวที่เรียกได้ว่าสร้างความสะดวกสบายอย่างมากเลยครับ เช่น ผมอาจจะเขียน Logic ให้มีการกวาดหา Header ทั้งหมดเพื่อนำมา generate เป็น table of content ได้ ถ้าสนใจไว้ผมเขียนอีกบทความสอนทำนะครับ

  1. เนื่องจากตอนเราสร้าง Schema เราบอกว่าจะ กวาดทุก .mdx files ใน directory articles เพราะฉนั้นอย่าลืม สร้าง directory articles ที่ root ของ project directory นะครับ

มาเขียนบทความกันเถอะ

และใน directory articles นี้ เราลองมาสร้างตัวอย่างบทความกันครับ เริ่มจากการ สร้างไฟล์ชื่อ post-01.mdx โดยใส่เนื้อหาตามนี้

---
title: This is title
description: This is description
---
 
This is content

field ที่เราประกาศไว้ใน Schema จะอยู่ในช่วงที่เปิดและปิดด้วยเครื่องหมาย --- ครับ ถัดจาก --- จะเป็น body (เนื้อหา) ของบทความ

ถ้าถึงขั้นตอนนี้ ผู้อ่านยังไม่ได้ run ตัว NextJs ก็สามารถใช้คำสั่ง

npm run dev

ได้เลยนะครับ

ซึ่งเราน่าจะเจอกับข้อความนี้ใน terminal

> my-blog@0.1.0 dev
> next dev
 
- ready started server on 0.0.0.0:3000, url: http://localhost:3000
- event compiled client and server successfully in 2.5s (20 modules)
Contentlayer config change detected. Updating type definitions and data...
Generated 1 documents in .contentlayer
- wait compiling...
- event compiled client and server successfully in 192 ms (20 modules)

สิ่งสำคัญคือต้องมีคำว่่า Generated 1 documents in .contentlayer นะครับ หมายความว่าตัว contentlayer generate บทความเราได้ ถ้าไม่เจอข้อความนี้ลองกลับไปดูแต่ละขั้นตอนนะครับว่าข้ามอะไรไปหรือเปล่า

ย้อนกลับไปดูตอนเราสร้าง Schema นะครับ เราบอกว่าจะมี fields สองตัวก็คือ title และ description โดยทั้งคู่มี type เป็น string เพื่อความเข้าใจว่า Contentlayer ช่วย validate บทความเราได้อย่างไร สามารถลองลบ title ออกได้ครับ แล้วใน terminal น่าจะฟ้อง error ประมาณนี้

File updated: post-01.mdx
Warning: Found 1 problems in 1 documents.
 
 └── Missing required fields for 1 documents. (Skipping documents)
 
 "post-01.mdx" (of type "Article") is missing the following required fields:
 title: string
 
 
Generated 1 documents in .contentlayer

ประมาณนี้ครับ

ณ จุดนี้ ถ้าเราดูที่ root ของ project directory เราจะเจอ directory .contentlayer ตามรูปครับ

vscode after setup contentlayer

ลองดู .contentlayer/generated/Article/post-01.mdx.json ครับ จะสังเกตว่าตัว Markdown ของเราทั้งก้อนถูกแปลงเป็น JSON โดย field จะถูกแยกออกเป็น key เฉพาะของตัวเองเลย และเนื้อหาทั้งหมดของ Markdown (ตัวที่อยู่ใต้เครื่องหมาย ---) จะถือเป็น body ครับ

ทำหน้าเว็บสำหรับแสดงบทความ

ในส่วนนี้จะเป็นการแก้ตัว React เพื่อให้สามารถดึงข้อมูลมาแสดงผลครับ

เคลียร์ boilerplate

เพื่อไม่ให้สับสนกับเรื่องที่ไม่เกี่ยวข้อง เรามาเคลียร์ boilerplate ออกกันก่อนครับ

  1. แก้ไข globals.css ให้เหลือแค่นี้ครับ
@tailwind base;
@tailwind components;
@tailwind utilities;
  1. แก้ไข src/app/page.tsx ให้เหลือแค่นี้ครับ
export default function Home() {
  return (
    <>
      <h1>My Blog</h1>
    </>
  );
}
  1. เมื่อเรา run ตัว NextJs อีกครั้ง จะเจอกับหน้าเว็บที่มีเฉพาะข้อความ My Blog

แสดงรายการของบทความ

ปกติเรามักจะมีหน้าเว็บ 2 ประเภทนะครับ ประเภทแรกคือสำหรับแสดงบทความทั้งหมดที่เรามี อีกประเภทคือสำหรับแสดงบทความแต่ละบทความครับ

  1. สร้าง directory src/app/components และสร้างไฟล์ src/app/components/ArticleList.tsx ขึ้นมาครับ

  2. แก้ไข src/app/page.tsx ให้เป็นแบบนี้ครับ

import { allArticles } from 'contentlayer/generated'; // import articles ทั้งหมด (เราจะใช้เพื่อแสดงรายการบทความ)
 
export default function Home() {
  return (
    <>
      <h1>My Blog</h1>
      <hr className="border-1 " />
 
      {allArticles.map(
        // วน loop แสดงรายการบทความทั้งหมด
        (article) => (
          <>
            <article key={article.title}>
              <h2>{article.title}</h2>
              <p>{article.description}</p>
            </article>
            <hr className="border-1 " />
          </>
        ),
      )}
    </>
  );
}
// สังเกตว่าเวลาเราพิมพ์ article. จะได้ autocomplete ของ field ที่เราสร้างไว้ใน contentlayer ออกมาเลย เช่น title

Autocomplete ในที่นี้เรียกอีกอย่างได้ว่า type Intellisense ในอนาคต เราจะเจอคำว่า Intellisense บ่อยมาก ถ้าเจอก็ให้นึกว่ามันคือ ฟีเจอร์นึงของการเขียน code แล้วกันครับ แต่ส่วนตัวผมเรียกมันว่า ใบ้โค้ด เช่น autocomplete, code hint, code suggestion, หรือ ใบ้ member list ของ object ข้อดีคือทำให้เราเขียนโปรแกรมได้ไวขึ้น ลดโอกาสผิดพลาดจากการพิมพ์ผิด หรือป้องกัน Bug ที่เกิดจากการอ้างอิงถึง member หรือค่าที่ไม่มีอยู่จริง

  1. เพื่อให้เห็นภาพ เราลองไปสร้างบทความเพิ่มอีกสัก 1 บทความกันครับ โดยสร้างไฟล์ articles/post-02.mdx และใส่เนื้อหาตามนี้ครับ
---
title: This is a second post
description: This is the description of second post
---
 
This is content of second post
  1. ลองกลับมาดูหน้าเว็บใหม่อีกครั้ง จะเจอกับรายการบทความทั้ง 2 บทความที่เราสร้างไว้แล้ว
All blogs page
  1. แนะนำให้สร้าง React Component เพื่อจัดการการแสดงผลของรายการบทความ เช่น Article ที่รับ article เข้าไป แต่ทั้งนี้ก็แล้วแต่ความชอบของแต่ละคนครับ

แสดงบทความ

สร้างหน้าเว็บสำหรับแสดงบทความ

  1. เดี๋ยวเรากลับไปแก้ src/app/page.tsx ให้สามารถคลิกเพื่อเปลี่ยนหน้ากันก่อน
import { allArticles } from 'contentlayer/generated';
import Link from 'next/link';
 
export default function Home() {
  return (
    <>
      <h1>My Blog</h1>
      <hr className="border-1 " />
 
      {allArticles.map((article) => (
        <>
          <article key={article.title}>
            <h2>{article.title}</h2>
            <p>{article.description}</p>
            <Link
              className="text-blue-500"
              href={`${article.url}`}
            >
              Read more
            </Link>
          </article>
          <hr className="border-1 " />
        </>
      ))}
    </>
  );
}

สังเกตว่าเราเรียกใช้ article.url อ่าว แล้วเจ้า url นี้มันมาจากไหน?

ลองกลับไปดู contentlayer.config.ts ที่เราเคยเขียนกันครับ

import { defineDocumentType, makeSource } from 'contentlayer/source-files';
 
export const Article = defineDocumentType(() => ({
  name: 'Article',
  filePathPattern: `**/*.mdx`,
  contentType: 'mdx',
  fields: {
    title: { type: 'string', required: true },
    description: { type: 'string' },
  },
  computedFields: {
    url: {
      type: 'string',
      resolve: (article) => `/articles/${article._raw.flattenedPath}`,
    },
  },
}));
 
export default makeSource({
  contentDirPath: 'articles',
  documentTypes: [Article],
});

มันมาจาก computedFields นั้นเอง

ขอแปะ note ด้านล่างอีกครั้งครับ

computedFields ดูเพิ่มเติม computedFields เป็นตัวที่เรียกได้ว่าสร้างความสะดวกสบายอย่างมากเลยครับ เช่น ผมอาจจะเขียน Logic ให้มีการกวาดหา Header ทั้งหมดเพื่อนำมา generate เป็น table of content ได้ ถ้าสนใจไว้ผมเขียนอีกบทความสอนทำนะครับ

  1. เมื่อแก้แล้วลองสังเกตตัว Link ดูครับ มันจะชี้ไปที่ /articles/post-01 และ /articles/post-02 เลย
read more link
  1. เอาละ ทีนี้เรามาสร้างหน้าสำหรับแสดงบทความกันครับ

สร้างไฟล์ src/app/articles/[slug]/page.tsx และใส่ code ตามนี้

import { allArticles } from 'contentlayer/generated';
import { notFound } from 'next/navigation';
 
export default function ArticlePage({
  params: { slug }, // ดึงค่าจาก path เช่น post-01
}: {
  params: { slug: string };
}) {
  // หา article ที่มี slug ตรงกับที่เราส่งเข้ามา
  const article = allArticles.find(
    (article) => article._raw.flattenedPath === slug,
  );
 
  if (!article) {
    notFound(); // ถ้าไม่เจอก็ให้แสดงหน้า 404
  }
 
  return (
    <>
      <h1 className="text-3xl">{article.title}</h1>
      <p className="text-gray-500">{article.description}</p>
      <hr className="border-1 " />
      <article>
        {/* ใส่ content ของบทความ ตรงนี้เดี๋ยวเรามาแก้ไขกันต่อ */}
        <p>Content Here</p>
      </article>
    </>
  );
}
  1. ถึงจุดนี้ถ้าเราดูหน้าบทความ post-01 เราควรจะเห็นตามภาพนี้ครับ
post 01 blank page
  1. ต่อมา เรามาจัดการวิธีการแสดง content กันครับ ถ้าเราจำได้ ตัว content ของ post-01.mdx ก็คือ This is content ตามภาพด้านล่าง
---
title: This is title
description: This is description
---
 
This is content
  1. ซึ่ง Content ในส่วนนี้ เราจะเขียนเป็น Markdown สิ่งที่เราต้องการก็คือให้ตัว Contentlayer ช่วยแปลง Markdown เป็น HTML ให้เรา

โดยเราจะใช้ useMDXComponent มาช่วยเรา เริ่มจากการสร้าง custom component ชื่อ Mdx ขึ้นมาครับ

src/components/Mdx.tsx

import { useMDXComponent } from 'next-contentlayer/hooks';
 
type MdxProps = {
  code: string;
};
 
export function Mdx({ code }: MdxProps) {
  const Component = useMDXComponent(code);
 
  return <Component />;
}
  1. เสร็จแล้วเรียกใช้ Mdx ในหน้าบทความของเรา

แก้ไข src/app/articles/[slug]/page.tsx ตามนี้ครับ

import { Mdx } from '@/components/Mdx';
import { allArticles } from 'contentlayer/generated';
import { notFound } from 'next/navigation';
 
export default function ArticlePage({
  params: { slug },
}: {
  params: { slug: string };
}) {
  const article = allArticles.find(
    (article) => article._raw.flattenedPath === slug,
  );
 
  if (!article) {
    notFound();
  }
 
  return (
    <>
      <h1 className="text-3xl">{article.title}</h1>
      <p className="text-gray-500">{article.description}</p>
      <hr className="border-1 " />
      <article>
        <Mdx code={article.body.code} />
      </article>
    </>
  );
}
  1. สังเกตว่าหน้าบทความของเราจะ render Markdown แล้ว

ในขั้นตอนนี้ สามารถทดลองใส่ Markdown syntax เข้าไปได้เลยครับ

เช่นเรามาลองแก้ post-01.mdx ในส่วนของ Content จาก This is content ให้เป็นตามนี้กัน

---
title: This is title
description: This is description
---
 
# This is Header
 
## This is also Header
 
List item
 
- Item 1
- Item 2
- Item 3
 
> Note
 
`code`
  1. อ่าว!! ทำไม Format มันเพี้ยน ๆ ไม่เหมือนที่เราคิดละ แก้ไขยังไงดี เราไปดูกันในหัวข้อต่อไปครับ
article content broken format

ทำให้ TailwindCSS รองรับ Markdown

จากปัญหาเมื่อครู่ ถ้าเราคุ้นเคยกับตัว TailwindCSS เราจะรู้ว่ามันล้างบาง (pre-flight) style พื้นฐานทั้งหมดของเว็บเราเลย เช่นปรับขนาดของ H1 ให้เป็นเหมือนข้อความปกติ

เพราะฉนั้นเราต้องป้องกันไม่ให้ TailwindCSS ล้าง style ของเรา โดยการใช้ prose class ของ TailwindCSS ครับ

มาเริ่มกันเลย

  1. ติดตั้ง @tailwindcss/typography
npm install -D @tailwindcss/typography

ดูเพิ่มเติม @tailwindcss/typography

  1. เพิ่ม plugin ใน tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
    './src/components/**/*.{js,ts,jsx,tsx,mdx}',
    './src/app/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  theme: {
    extend: {
      backgroundImage: {
        'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
        'gradient-conic':
          'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
      },
    },
  },
  plugins: [require('@tailwindcss/typography')],
};
  1. แก้ไข src/components/Mdx.tsx เพิ่ม className=”prose” เข้าไป จะได้ประมาณนี้ครับ
import { useMDXComponent } from 'next-contentlayer/hooks';
 
type MdxProps = {
  code: string;
};
 
export function Mdx({ code }: MdxProps) {
  const Component = useMDXComponent(code);
 
  return (
    <div className="prose">
      <Component />
    </div>
  );
}
  1. เรียบร้อย!
article content good format

สรุป

เป็นไงบ้างครับ ติดปัญหาอะไรตรงไหนไหม ถ้ามันไม่เป็นไปตามที่เราหวังแนะนำลองอ่านอีกทีก่อนครับ เผื่อพลาดจุดสำคัญจุดไหนไป

ในบทความนี้เราได้สร้าง NextJS แอพขึ้นมา แล้วก็ลงพวก dependencies ต่าง ๆ ที่จำเป็นต่อการใช้งาน ต่อมาเราได้ทำการออกแบบ Schema ของเนื้อหาของเรา แล้วก็เขียน Content ของเราในรูปแบบ Markdown เสร็จแล้วเราก็มีการเขียน React components/pages สำหรับการแสดงบทความของเรานั่นเอง

ขออภัยหากบทความนี้มีข้อผิดพลาดใด ๆ หากท่านมีข้อเสนอแนะบอกผมได้เลยครับ (dech.rachadech@gmail.com) ขอบคุณครับ

@/utils/convertToThaiString