Tạp chí Thợ Code

Xây dựng Headless Weblog/ CMS tận dụng sức mạnh của Notion và AWS Amplify - Phần 1

Đầu tuần còn còn uể oải chả có kèo nhậu nào lại còn giữa tháng lương chưa kịp xài đã hết nên đành mời quý bạn đọc giải trí bằng sự trở lại của series Bán nguyệt san Nhật ký mây mưa cùng con nhà nghèo Tí Dev.

Vào thời điểm cuối năm 2024, thuộc thế kỷ 21 tại hành tinh Trái Đất, giữa sự phát triển vượt bật của nền văn minh Trí tuệ nhân tạo tạo sinh (Generative AI) loài người đã tìm ra rất nhiều phương pháp để có thể xây dựng một CMS/ weblog từ đơn giản nhanh chóng như sử dụng trọn gói các dịch vụ của Ghost, Substack, Medium hay trung thành với những giải pháp truyền thống trường tồn qua năm tháng như WordPress, Drupal hay Umbraco. Nhưng song song đó cũng có một bộ phận cấp tiến không nhỏ thích sự tiện nghi của các dịch vụ trọn gói, nhưng cũng phải vừa đảm bảo khả năng can thiệp tốt vào hệ thống vào mã nguồn, nôm na là “hybrid” nhạc nào cũng nhảy được, dân gian thường nhắc đến hiện tượng đó với tên gọi trìu mến Headless hay DB-less Content Management System (CMS).

Vậy, hôm nay chúng ta sẽ cùng xây dựng một CMS theo tiêu chuẩn content-driven design principle và triển khai trên AWS Amplify để có thể “Biến ý tưởng thành ứng dụng chỉ trong vài giờ”.

Headless CMS là gì?

Trước khi nói về chuyện đời nay thì người ta thường “ôn cố tri tân” một chút, thật ra chủ yếu mấy người lớn tuổi mới hay mở đầu bằng “hồi xưa…”

Hồi xưa, cách đây hơn 20 năm, WordPress (WP) ra đời dựa trên nền tảng và nguồn cảm hứng từ b2/Cafelog một dự án cá nhân đơn giản. Mặc dù được xây dựng trên ngôn ngữ PHP với DB là mySQL nhưng có những thời điểm WP hot đến nỗi đó là một nghề hái ra tiền nếu chỉ cần biết cách cài đặt các plugins hay theme trên WP, nếu biết cách edit một file .php để nulled hay thay đổi gì đó thì cũng được liệt vô hàng cao thủ, còn những đại cao thủ viết plugins viết theme thì quá đỉnh cao. Lịch sử còn lại đến ngày hôm nay thì đã như các bạn đã biết.

Để triển khai hệ thống WP đơn giản chúng ta cần phải có tối thiếu Web Server và DB Server. Hai hệ thống này có thể đặt riêng hay chung trên cùng một server đều được.

画像が読み込まれない場合はページを更新してみてください。

Kiến trúc Triển khai một hệ thống WP hiện đại

画像が読み込まれない場合はページを更新してみてください。

Kiến trúc phần mềm một hệ thống Legacy CMS

Như bạn thấy đấy, ngồi dựng hết mớ này lên, rồi customize theo ý thích nữa thì xong chắc down hết mood viết luôn, chưa kể là còn phải lo update phiên bản mới các kiểu. Headless CMS chính là một trong những giải pháp sẽ cho bạn sự tập trung vào việc sáng tạo nội dung cũng như là vẫn có thể lâu lâu code kiếc chấm phá một chút vài đoạn mã xanh xanh đỏ cho thêm phần thi vị.

画像が読み込まれない場合はページを更新してみてください。

Một hệ thống Headless CMS, giao diện người dùng hoàn toàn tách biệt với phần quản lý, giao tiếp thông qua API.

Có nhiều dịch vụ như Strapi, Tina, Webiny cung cấp phần quản lý này nhưng chúng ta sẽ dùng Notion.so bởi vì:

  • Miễn phí
  • Hỗ trợ rất rất tốt cho việc soạn thảo nội dung
  • Quan trọng: cung cấp API để tích hợp vào các ứng dụng khác

DB-less

Thuật ngữ này vẫn còn nhiều tranh luận, từng có một giai đoạn các CMS DB-less đi theo hướng không sử dụng các hệ quản trị dữ liệu relational hay non-relational database mà sử dụng các file XML lưu trữ ngay tại chính web server.

Trong phạm vi bài viết này chúng ta sẽ tạm hiểu đơn giản là không quan tâm đến việc lựa chọn hay vận hành các hệ thống database phục vụ cho CMS, mà để một bên khác chuyên nghiệp hơn xử lý phần này.

Xây dựng User UI

Astro.build là một web framework với các tiêu chí thiết kế như Content-driven, Server-first, Fast by default, Easy to use, Developer-focused sẽ là ứng viên sáng giá.

画像が読み込まれない場合はページを更新してみてください。

Tốc độ nhanh rất ấn tượng cho khả năng SEO bởi vì toàn bộ nội dung sẽ được generate dưới dạng static ở mỗi lần build, sau đó không cần tốn thời gian query vào API. Hỗ trợ nhiều UI framework gồm React, Vue, Svelte, Preact, Solid…

Để sử dụng Astro, bạn cần có Node.js phiên bản v18.17.1 hoặc v20.3.0 mới hơn, phiên bản v19 không rõ vì sao không hỗ trợ.

Khởi tạo dự án với Astro Framework

Chạy lệnh npm create astro@latest -- --template blog để khởi tạo một dự án mới với teamplate blog. Có rất nhiều mẫu và ví dụ mẫu tại https://github.com/withastro/astro/tree/main/examples, bạn có thể tham khảo.

画像が読み込まれない場合はページを更新してみてください。

画像が読み込まれない場合はページを更新してみてください。

Sau khi khởi tạo thành công, ta có cấu trúc dự án tương tự như bên dưới.

画像が読み込まれない場合はページを更新してみてください。

Viết code đọc dữ liệu từ Notion

Đến đây thì bạn đã có một blog với sử dụng cú pháp makrdown (md, mdx files). Ngay tại bước này bạn đã có thể chạy npm run dev, deploy npm run build và sử dụng, tuy nhiên việc soạn markdown khá là buồn tẻ cũng như là thiết kế này vẫn chưa đúng với tinh thần Headless Weblog.

Vì lẽ đó nên ta chạy lệnh npm install @notionhq/client để cài đặt thêm thư viện làm việc với Notion.

Tạo file .env tại thư mục gốc để thiết lập các giá trị biến môi trường:

NOTION_API_SECRET=your_notion_api_key_here
NOTION_DATABASE_ID=your_notion_database_id_here

Astro đã hỗ trợ sẵn việc đọc dữ liệu từ các biến môi trường khá dễ dàng, tất cả các biến này sẽ sử dụng ở phía server-side để đảm bảo an toàn, chỉ những biến có tiền tố PUBLIC_ mới có thể đọc ở phía client-side. Tuy nhiên, không như Dotenv, với Astro bạn cần sử dụng lệnh import.meta.env.[ENV_NAME] để truy xuất giá trị của các biến môi trường này.

SECRET_PASSWORD=password123
PUBLIC_ANYBODY=there

Ý tưởng của ứng dụng này là hệ thống sẽ đọc dữ liệu từ Notion thông qua API bên trên và chuyển cấu trúc của Notion thành markdown để hiển thị trên Astro. Do đó, ta cần cài đặt các thư viện sau

npm install notion-to-md
npm install marked

Kiến trúc một ứng dụng Astro framework

画像が読み込まれない場合はページを更新してみてください。

Source:

Tạo file src/utils/getNotionData.ts

Hàm getAllData() sẽ kiểm tra đã có cache hay chưa để gọi đến API của Notion và trả về một object posts theo cấu trúc của Notion. Bạn có thể tham khảo thông tin tại https://developers.notion.com/docs/working-with-page-content

Hàm getPostContent() xử lý chuyển đổi sang markdown.

import { Client } from "@notionhq/client";
import { NotionToMarkdown } from "notion-to-md";
import { marked } from "marked";

const notion = new Client({ auth: import.meta.env.NOTION_API_SECRET });
const databaseId = import.meta.env.NOTION_DATABASE_ID;
let cachedPosts: CollectionEntry<"blogs">[] | null = null;

export async function getAllData(): Promise<CollectionEntry<"blogs">[]> {
    if (cachedPosts) {
        return cachedPosts;
    }
    try {
        console.log('notion.databases');
        const response = await notion.databases.query({
            database_id: databaseId,
            filter: {
                property: "Published",
                checkbox: {
                    equals: true,
                },
            },
            sorts: [
                {
                    property: "PublishedDate",
                    direction: "descending",
                },
            ],
        });
        const posts = response.results.map((page: any) => ({
            id: page.id,
            slug: page.properties.Slug.rich_text[0].plain_text,
            description: page.properties.Text.rich_text[0].plain_text,
            pubDate: new Date(page.properties.PublishedDate.date.start),
            updateDate: new Date(page.last_edited_time),
            author: page.properties.author,
            draft: page.properties.Published.checkbox,
            // tags: page.properties.tags.multi_select.map((tag: any) => tag.name),
        }));
        cachedPosts = posts;
        return posts;
    } catch (error) {
        console.error("Error fetching data from Notion:", error);
        throw error;
    }
}


const n2m = new NotionToMarkdown({ notionClient: notion });
export async function getPostContent(id: string) {
    console.log('postId', id);
    const mdblocks = await n2m.pageToMarkdown(id);
    const mdString = n2m.toMarkdownString(mdblocks);
    const parsedContent = await marked.parse(mdString.parent);
    console.log('parsedContent', parsedContent);
    return parsedContent;
}

Viết code hiển thị dữ liệu trên blog

Cập nhật file src/pages/blog/[...slug].astro thay cho code trong template. Mục tiêu thay vì đọc dữ liệu các file .md và .mdx trong thư mục src/content/blog thì chúng ta sẽ hiển thị trực tiếp nội dung đã chuyển đổi sang markdown bằng hàm getPostContent() đã xây dựng bên trên.

---
import { getAllBlogPosts, getPostContent } from "../../utils/getNotionBlogPosts";
import BlogPost from "../../layouts/BlogPost.astro";

export async function getStaticPaths() {
  const posts = await getAllData();
  return posts.map(post => ({
    params: { slug: post.slug },
    props: { post },
  }));
}
const { post } = Astro.props;
const content = await getPostContent(post.id);
---
<BlogPost {...post}>
  <article set:html={content} />
</BlogPost>

Thiết lập Notion

Vào Notion.so > Settings > Connections > Develop or manage integrations. Chọn New integration. Sau khi tạo xong bạn sẽ có Internal Integration Secret chính là giá trị NOTION_API_SECRET trong file .env.

画像が読み込まれない場合はページを更新してみてください。

Tiếp đến bạn tạo một Page với kiểu Table có các cột như minh họa bên dưới. Hoặc để tiết kiệm thời gian bạn có thể Duplicate từ link của tôi https://selective-saxophone-e3a.notion.site/120707b9c8ae80a69319e7d8098360ec?v=c3a84dd107b64f4ab576ed4e13ccfcf0&pvs=4

画像が読み込まれない場合はページを更新してみてください。

Để lấy thông tin NOTION_DATABASE_ID bạn hãy chú ý vào đoạn query ở sau link notion.so như bên dưới. Link này có thể dễ dàng nhìn thấy ngay tại thanh address bar của trình duyệt hoặc thông qua tính năng Share.

画像が読み込まれない場合はページを更新してみてください。

Bây giờ bạn có thể chạy lệnh npm run dev.

Triển khai trên AWS Amplify

画像が読み込まれない場合はページを更新してみてください。

Thiết lập Repository chứa source code

Thực hiện mọi thao tác như thông thường để thêm một local repository vào GitHub. Nhớ kiểm tra xem file .gitignore đã có loại trừ file .env chưa để tránh lọt lộ các thông tin nhạy cảm.

git remote add origin https://github.com/youraccount/awsheadlessblog.git
git branch -M main
git push -u origin main
Tạo một App trên Amplify

Lựa chọn như ảnh minh họa

画像が読み込まれない場合はページを更新してみてください。

Ở màn hình Add repository and branch bạn sẽ chọn repository vừa mới đưa lên GitHub cũng như branch để deploy. Bấm nút Next để sang màn hình App settings. Trong màn hình này bạn xổ xuống phần Advanced settings để thêm các biến môi trường như đã thêm trong file .env ở trên.

画像が読み込まれない場合はページを更新してみてください。

Bấm Next và cuối cùng bấm Save and deploy.

Chờ đợi và bùm…

画像が読み込まれない場合はページを更新してみてください。

Quay lại IDE fix bug sau đó push lại source, khi hoàn tất Amlify sẽ nhận trigger để tự động build lại.

画像が読み込まれない場合はページを更新してみてください。

Tạm kết Phần 1

Về cơ bản bạn đã xây dựng xong một blog đạt các tiêu chí đề ra.

Tuy nhiên còn rất nhiều thứ cần phải làm tiếp như là:

  • Bổ sung khả năng SEO Onpage tốt hơn.
  • Xử lý các thành phần của Notion.
  • Nâng cấp khả năng cache để tránh việc query vào Notion API liên tục.
  • Thiết lập khả năng tự động build lại website
  • Giám sát hệ thống

Ngày cũng đã gần tàn, năng lượng cũng đã trao hết cho giới tư bản nên xin được hẹn quý bạn đọc ờ kỳ sau.

Ảnh cover bởi DALL-E, xúi nó vẽ bởi Tui.

Biên soạn: Tí Dev. Hiệu đính & bắt lỗi chính tả: Anh Dũng.

Sài Gòn, những ngày giữa tháng 10/2024 mưa lác đác chiều tối trời ui ui buồn ngủ không lời nào kể xiết.