Blogtinum bắt đầu từ một mong muốn đơn giản: phần bền vững của một ấn phẩm phải là nội dung đã viết, không phải ứng dụng đang hiển thị nội dung đó. Bài viết của tôi nên luôn là các file Markdown bình thường, có thể đọc, so sánh thay đổi, di chuyển và build lại mà không phải xin một nền tảng hosted cho phép export.

Đây không phải câu chuyện viết lại mọi dependency. Blogtinum sở hữu content contract, orchestration, validation và output pipeline, đồng thời chủ động dùng các công cụ chuyên biệt như markdown-it, Shiki, KaTeX, Vue server renderer và esbuild. Mục tiêu là kiểm soát hệ thống, không phải tự viết một Markdown parser chỉ để chứng minh điều gì đó.

Vì sao lại xây thêm một publishing engine?

Site trước đây dùng Hexo, và Hexo đã làm tốt nhiệm vụ của nó: nhận source content, kết hợp với theme và plugin, sau đó tạo ra một website tĩnh. Tôi chuyển đi không phải vì static site generator thất bại, mà vì ấn phẩm dần trở thành một sản phẩm có yêu cầu cụ thể hơn:

  • một repository cần build nhiều site với cấu hình tường minh;
  • trang public và Admin preview phải có hành vi Markdown giống hệt nhau;
  • các biến thể song ngữ cần identity cấp hệ thống, thay vì một quy ước tên file ẩn trong theme;
  • quy trình xuất bản cần hỗ trợ cả file local lẫn GitHub;
  • metadata, redirect, feed, search và social preview cần dùng chung một typed model;
  • tính năng tương tác phải nhỏ gọn, thay vì biến mọi trang thành ứng dụng render phía client.

Khi sở hữu pipeline, archive cũng bớt phụ thuộc vào hệ sinh thái theme. Nội dung nằm tại content/<site>/posts/<slug>/index.<lang>.md, media nằm cùng thư mục, và Git ghi lại mọi thay đổi có ý nghĩa. Cách render có thể thay thế; source vẫn còn nguyên giá trị.

Publishing system chỉ là tạm thời. Plain text với schema rõ ràng mới là tài sản.

Static site generator là gì?

Static content generator chuyển phần lớn công việc từ request time sang build time. Thay vì yêu cầu server truy vấn database rồi lắp lại cùng một bài viết cho từng lượt đọc, nó thực hiện công việc đó một lần và ghi kết quả ra đĩa.

flowchart LR A[Markdown + frontmatter] --> B[Load và validate] B --> C[Render content] C --> D[Prerender các route] D --> E[HTML, CSS, JS, feed, search] E --> F[CDN hoặc static host]

Khi có request, host gần như chỉ ánh xạ URL tới một file. Nếu NN trang được build một lần và mỗi trang được request RR lần, phần render tốn kém gần với O(N)O(N) cho mỗi deployment hơn là O(NR)O(NR) trên toàn bộ lượt đọc. Sự đơn giản này mang lại nhiều lợi ích thực tế:

Thuộc tính Static build Ứng dụng xử lý theo request
Request bài viết thông thường Đọc file đã chuẩn bị Chạy application logic
Mở rộng tải CDN xử lý các lượt đọc lặp lại Runtime phải hấp thụ traffic
Bề mặt tấn công Không có content database trên public path Server và data store luôn mở
Cập nhật nội dung Build và deploy lại Thường hiện ra ngay sau khi ghi
Cá nhân hoá theo người đọc Hạn chế nếu không có API Phù hợp tự nhiên

Hexo, Hugo, Eleventy và nhiều công cụ documentation đều đi theo mô hình tổng quát này. Chúng khác nhau về ngôn ngữ, theme system, plugin model và convention nội dung, nhưng “static” mô tả output, không có nghĩa tooling bên trong đơn giản hay thiếu năng lực.

Vì sao thay Hexo trong trường hợp này?

Hexo vẫn là lựa chọn hợp lý cho một blog thông thường. Nó có theme trưởng thành, plugin ecosystem quen thuộc và con đường ngắn từ Markdown tới deployment. Blogtinum đánh đổi sự tiện lợi đó để lấy một contract nhỏ hơn, được thiết kế riêng cho repository này.

Yếu tố quyết định là một rendering path duy nhất. Một plugin configuration trong site generator và một bản mô phỏng thứ hai trong editor preview sớm muộn sẽ khác nhau. Code block, relative image, heading ID hoặc custom container có thể trông đúng lúc soạn thảo nhưng thay đổi sau khi publish. Blogtinum chỉ expose một renderer từ @blogtinum/core; CLI build và Admin preview đều gọi nó.

Một renderer, hai consumer

Public build và live preview có thể bọc rendered HTML theo cách khác nhau, nhưng không duy trì hai cách hiểu Markdown. Sửa lỗi renderer sẽ sửa cả hai bề mặt.

Vì vậy migration này không nói rằng “custom luôn tốt hơn”. Đây là quyết định về boundary: dùng một generator tổng quát khi convention của nó còn phù hợp, và sở hữu orchestration khi contract của ấn phẩm đã trở thành chính tính năng cần xây.

Engine Blogtinum hoạt động như thế nào?

Repository được tách theo trách nhiệm, với dependency direction đơn giản:

  1. @blogtinum/shared-types định nghĩa post, site settings, author, validation result và storage contract.
  2. @blogtinum/core load content, render Markdown, tạo excerpt và reading time, validate reference, nhóm translation, đồng thời sản xuất dữ liệu SEO/search/feed.
  3. @blogtinum/cli chọn site, chạy publish gate, yêu cầu public Vue app SSR từng route, rồi ghi toàn bộ static output.
  4. apps/site biến typed page data thành HTML. Client entry chỉ hydrate những phần tương tác cần browser state.
  5. apps/admin-apiapps/blog-admin cung cấp authoring mà không trở thành dependency của public site.

1. Load một content model duy nhất

YAML frontmatter mô tả metadata ổn định như title, slug, language, category, tag, cover, publication flag và alias. Tên file xác định language variant, còn translationKey nhóm các variant thành một bài viết logic.

Loader parse source, normalize boolean tùy chọn, resolve route, render body và tạo các giá trị mà author không nên duy trì thủ công:

packages/core/src/content/load.ts
const { html, toc } = renderer.render(markdown, { assetBase });
const text = plainText(html);
const words = wordCount(text);

return {
  html,
  toc,
  wordCount: words,
  readingTime: readingTime(words),
};

2. Render Markdown đúng một lần

@blogtinum/core cấu hình markdown-it một lần, sau đó cài các syntax feature dùng chung cho production và preview. Shiki highlight fenced code; KaTeX render math; Mermaid source trở thành diagram được hydrate lũy tiến; các plugin bổ sung anchor, footnote, admonition, tab và media path resolution.

Để ước lượng reading time, engine dùng quan hệ đơn giản như

tread=max(1,w220),t_{read} = \max\left(1, \left\lceil \frac{w}{220} \right\rceil\right),

với ww là số từ được derive. Đây cố ý chỉ là ước lượng, không phải lời hứa về tốc độ của người đọc.

3. Prerender route hoàn chỉnh

CLI không chỉ biến Markdown thành một HTML fragment. Nó chuẩn bị typed PageData cho home, post, archive, category, tag, series và standalone page. Vue SSR render các view này thành page markup hoàn chỉnh, rồi generator ghi từng route vào thư mục cuối cùng.

Cùng một build còn tạo:

  • theme CSS, font, media và browser bundle nhỏ;
  • sitemap.xml, RSS, canonical và alternate-language metadata;
  • OpenGraph, Twitter metadata và JSON-LD;
  • static search index được Fuse.js sử dụng;
  • social image sinh tự động khi bài không có cover do author cung cấp;
  • _redirects cho các legacy alias.

4. Chỉ hydrate nơi tương tác có ích

Bài viết đã dùng được trước khi JavaScript chạy. Search, table-of-contents scroll-spy, Mermaid, copy button, tab, overlay và theme switch được enhance trong browser. Chúng là các island nhỏ quanh static content, không phải điều kiện để có bản render đầu tiên có thể đọc.

Tip

“Static-first” không có nghĩa “không JavaScript”. Nó có nghĩa HTML mang trải nghiệm chính, còn JavaScript phải chứng minh giá trị bằng một tương tác cụ thể.

Các syntax được hỗ trợ

Chính bài viết này là một renderer check. Các heading phía trên tạo table of contents, diagram được viết bằng Mermaid, công thức dùng KaTeX, còn code block có filename và highlighted lines.

Markdown chuẩn hỗ trợ paragraph, strong emphasis, emphasis, list, link, block quote, inline code, image và table. Relative media được resolve từ thư mục post, giúp source giữ tính portable:

Cover Blogtinum được lưu cùng bài viết

Fenced block hỗ trợ syntax highlighting, copy control, filename tùy chọn và line range. Math hoạt động inline như E=mc2E = mc^2, hoặc theo display block. Footnote giữ phần giải thích dài khỏi câu chính.[1]

Admonition truyền tải mục đích mà không cần tự tạo raw HTML cho từng callout:

Hãy kiểm chứng sample content

Các bài chỉ được tạo để bổ sung dữ liệu cho development site mang provenance label AI Generated. Đây không phải taxonomy tag và không tạo archive route. Người đọc nên kiểm chứng các bài sample đó trước khi dựa vào thông tin bên trong.

Hướng dẫn theo từng môi trường có thể dùng chung một tab block:

Local adapter ghi Markdown và media đi kèm trực tiếp vào content/ trong lúc development.

Hosted adapter ghi qua GitHub API để mỗi lần publish vẫn là một Git change có thể review.

:::

Nội dung hỗ trợ dài hơn có thể dùng native details disclosure:

Phần nào vẫn đọc được khi chưa có client enhancement?

Article body, heading, link, code, math output, table và tab panel đều có trong server-rendered document. Browser code chỉ bổ sung tiện ích và hành vi có state.

Admin như một headless CMS dựa trên Git

Admin của Blogtinum “headless” theo nghĩa thực dụng: nó chỉnh sửa cùng một content model nhưng không phục vụ public article theo từng request. Public site chỉ phụ thuộc vào generated artifacts; authoring interface có thể offline, redeploy hoặc thay thế mà không làm đổi URL của người đọc.

Vue Admin giao tiếp với Express BFF qua typed JSON contract. Hai storage adapter cùng implement một boundary:

  • LocalFsAdapter đọc và ghi trực tiếp repository trong local development.
  • GitHubApiAdapter đọc file và tạo commit qua GitHub cho hosted authoring.

Editor quản lý frontmatter, Markdown, media, language variant, local draft recovery, revision history và publishing state. Preview gọi shared core renderer thay vì tái tạo Markdown trong browser. Một publish operation validate content trước khi đưa thay đổi tới deployment branch; sau đó static build bình thường tạo ra public site.

Cách tách này giữ trách nhiệm rõ ràng: Admin là content client, Git là durable store, generator là delivery pipeline, và CDN phục vụ người đọc.

Khi static-first không phù hợp

Static generation là mặc định rất tốt cho article, documentation, portfolio và nội dung có tỷ lệ đọc cao hơn ghi. Nó không tự động đúng cho mọi sản phẩm. Dashboard chứa private data theo từng user, inventory đổi từng giây, hoặc workflow phải xác nhận transaction tại request time đều cần runtime service.

Blogtinum cũng chấp nhận các đánh đổi:

  • thay đổi published content cần build và deployment;
  • archive rất lớn có thể cần incremental generation trong tương lai;
  • comment, form, analytics và private data cần service hoặc API bên ngoài;
  • custom contract phải được bảo trì, thay vì thừa hưởng mọi feature từ một ecosystem.

Quy tắc không phải “biến mọi thứ thành static”. Quy tắc là “giữ stable reading path ở dạng static, sau đó thêm runtime boundary nhỏ nhất cho hành vi thực sự dynamic”.

Lời chào đúng nghĩa

Blogtinum tồn tại để source tiếp tục đơn giản và delivery path luôn tường minh. Markdown là archive. Core là cách diễn giải. CLI biến cách diễn giải đó thành các file bền vững. Public UI trình bày chúng, còn Admin giúp cùng một model trở nên dễ chỉnh sửa.

Đó là lời hứa nhỏ hơn việc thay thế mọi publishing platform, nhưng là lời hứa tôi có thể inspect, test và duy trì.


  1. “Sở hữu nội dung” nghĩa là giữ original text, media, history và một schema có thể build lại. Nó không có nghĩa tránh dùng open-source library; các thư viện đó là implementation detail có thể thay thế phía sau contract của repository. ↩︎