Bên cạnh blog Thuanbui này, mình còn có khá nhiều blog cá nhân khác được duy trì trong nhiều năm qua. Một số blog vẫn được cập nhật thường xuyên, nhưng cũng có vài blog gần như không còn viết thêm bài mới nữa.
Tuy nhiên, dù không cập nhật nội dung, mình vẫn phải duy trì hệ thống: cập nhật plugin, cập nhật core, quản lý server, database, backup… Những việc này thực ra không khó, nhưng lại tốn thời gian cho những website gần như không còn cập nhật nội dung.
Vì vậy mình đã bắt đầu nghĩ đến phương án mới: chuyển các blog ít cập nhật sang dạng trang tĩnh (static site) theo mô hình Jamstack. Với static site, mọi thứ đơn giản hơn rất nhiều: không cần database, không cần chạy PHP, không phải lo plugin bị lỗi hay vấn đề bảo mật. Chỉ cần build ra HTML và deploy lên CDN là xong.
Đây cũng là cơ hội để mình thử nghiệm và tìm hiểu thêm các công nghệ mới của web development. Ít nhiều chắc chắn sẽ giúp ích cho công việc Laravel Developer hiện tại.
Sau khi tìm hiểu một thời gian về các công cụ tạo static site, mình quyết định chọn Astro, vì các lý do:
-
Astro đang ngày càng phổ biến trong cộng đồng static site (https://stackcrawler.com/most-popular-static-site-generator)
-
Gần đây Cloudflare đã mua lại Astro, cho thấy tiềm năng phát triển lâu dài của framework này. (https://astro.build/blog/joining-cloudflare/)
-
Một số blogger và developer mà mình theo dõi cũng đã chuyển sang Astro, ví dụ: Chris Lema đã chuyển qua Astro sau 20 năm dùng WordPress
-
Ngay cả blog của tác giả OpenClaw cũng đang sử dụng Astro: https://steipete.me/
Thật ra, mục tiêu cuối cùng của mình là chuyển 2 blog chính: Thuanbui.me và Yeuchaybo.com sang Astro. Tuy nhiên, trước khi làm vậy, mình muốn thử nghiệm trên một blog khác trước để rút kinh nghiệm. Blog mình chọn để làm chuột bạch đầu tiên là balodeplao.com - blog về du lịch, trải nghiệm của hai vợ chồng mình, đã rất lâu không có bài viết mới.
Trong bài viết này, mình sẽ chia sẻ lại toàn bộ quá trình migrate một blog từ WordPress sang Astro, từ việc export dữ liệu, chuyển đổi nội dung, xử lý hình ảnh, cho đến deploy static site.
Hy vọng kinh nghiệm này sẽ hữu ích cho các bạn đang có cùng ý định chuyển đổi từ WordPress sang Astro framework.
Stack sử dụng
Dưới đây là tech stack mình sẽ sử dụng cho blog khi chuyển qua Astro
| Layer | Công nghệ | Chi phí |
|---|---|---|
| Framework | Astro | Free |
| Content | GitHub (Markdown files) | Free |
| Images | Cloudflare R2 | Free (10GB) |
| Hosting | Cloudflare Pages | Free |
| Comments | Giscus | Free |
| Online Editor | Sveltia CMS | Free |
Yêu cầu hệ thống
Mình sử dụng Macbook Air M2 để xử lý công việc. Khuyến khích mọi người sử dụng Linux hoặc macOS để tiện thao tác. Nếu đang dùng Windows thì có thể sử dụng WSL2 để chạy Linux.
-
Đã cài đặt Node.js v20+ ( dùng lệnh
node --versionđể kiểm tra) -
Đã có sẵn tài khoản Github (free) và Cloudflare (free)
-
Đã cài đặt Git (
git --version) và Github CLI -
GitHub CLI đã cài và đăng nhập (
gh auth status) -
Đã cài đặt rclone đã cài
-
Blog WordPress đang chạy
Công việc sẽ gồm 4 phần chính
-
Giai đoạn 1 - Chuyển đổi nội dung từ WordPress qua Markdown
-
Giai đoạn 2 - Cài đặt Astro
-
Giai đoạn 3 - Upload ảnh lên Cloudflare R2
-
Giai đoạn 4 - Đồng bộ lên Github và deploy lên Cloudflare Worker
Ngoài ra còn 2 giai đoạn phụ: Cài đặt Sveltia CMS để chỉnh sửa nội dung blog tiện lợi hơn và Giscus cho tính năng comment của blog, sẽ không được sử dụng cho blog balodeplao.com. Bao giờ mình chuyển đổi blog Thuanbui.me này qua Astro sẽ có bài hướng dẫn hai cái đó sau.
Bài viết [Phần 1] hôm nay sẽ chia sẻ về Giai đoạn 1 - Chuyển đổi nội dung từ WordPress qua Markdown. Đây là bước tốn nhiều thời gian nhất để bảo đảm nội dung và hình ảnh trên blog được giữ trọn vẹn sau khi chuyển qua Astro.
1. Export nội dung từ WordPress
WordPress lưu nội dung bài viết trong database. Trong khi đó các công cụ tạo static site như Astro, Hugo,… thường sử dụng Markdown file để quản lý nội dung, theo mô hình Jamstack (không cần database).
Vì vậy khi migrate từ WordPress sang Astro, bước đầu tiên là export nội dung từ WordPress, sau đó convert sang Markdown để dùng cho static site.
-
Vào WordPress Admin → Tools → Export → All Content
-
Click Download Export File
-
Lưu file
.xmlvề máy
2. Tạo thư mục làm việc
Tạo thư mục trên máy để xử lý file xml vừa mới tải về
mkdir ~/blog-migrationmv ~/Downloads/*.xml ~/blog-migration/cd ~/blog-migration3. Xử lý link ảnh
Chú ý: bước này không bắt buộc, bạn có thể bỏ qua nếu muốn giữ nguyên cấu trúc nội dung blog hiện tại.
Trên blog cũ, khá nhiều hình ảnh được chèn trong bài viết không phải ảnh gốc full size mà ảnh thumbnail (size Large / Medium). Các file này thường có thêm phần thông tin size được chèn sau tên, ví dụ filename-900x600.jpg
Mình muốn tải file gốc filename.jpg thay vì ảnh thumbnail nên cần xử lý file xml trước khi chuyển đổi qua markdown.
Tạo file mới có tên gọi wp-image-fixer.sh trong thư mục blog-migration và copy nội dung này vào
https://gist.github.com/10h30/f6720ebbad3d5acd40e20a9883690bcb
#!/usr/bin/env bash# ==============================================================================# Replaces resized WordPress image URLs with originals in an XML export file.# Usage:# ./wp-image-fixer.sh # dry-run: scan and log results# ./wp-image-fixer.sh --apply # apply replacements from cached log# ==============================================================================set -euo pipefailLOG=".wp-image-check.log"URLS=".wp-image-urls.tmp"MAP=".wp-image-map.txt"APPLY=falsePARALLEL=50TIMEOUT=5[[ "${1:-}" == "--apply" ]] && APPLY=trueinfo() { echo "[info] $*"; }success() { echo "[ok] $*"; }warn() { echo "[warn] $*"; }die() { echo "[error] $*" >&2; exit 1; }# ------------------------------------------------------------------------------# Cleanup on exit or Ctrl+C# ------------------------------------------------------------------------------cleanup() { rm -f "$URLS" "${LOG}.lock" kill 0 2>/dev/null || true}trap cleanup INT TERMtrap 'rm -f "$URLS" "${LOG}.lock"' EXIT# ------------------------------------------------------------------------------# Detect existing log / prompt for input# ------------------------------------------------------------------------------if $APPLY; then [[ -f "$LOG" ]] || die "No scan found. Run the script without --apply first." INPUT=$(grep '^FILE|' "$LOG" | cut -d'|' -f2) info "Existing scan found for: $INPUT" echo read -rp "Apply fixes using cached results? (y/N): " confirm [[ "$confirm" != "y" ]] && exit 0else if [[ -f "$LOG" ]]; then info "Removing previous scan results..." rm -f "$LOG" fi read -rp "Enter WordPress XML export file: " INPUT [[ -f "$INPUT" ]] || die "File not found: $INPUT" echo "FILE|$INPUT" > "$LOG"fi# ------------------------------------------------------------------------------# DRY RUN — scan images and check originals# ------------------------------------------------------------------------------if ! $APPLY; then echo info "Extracting resized image URLs..." grep -oE 'https?://[^"[:space:]]+-[0-9]+x[0-9]+\.(jpg|jpeg|png|webp)' "$INPUT" \ | sort -u > "$URLS" TOTAL=$(wc -l < "$URLS" | tr -d ' ') info "Found $TOTAL resized image URLs" echo if [[ "$TOTAL" -eq 0 ]]; then warn "No resized images found. Nothing to do." rm -f "$LOG" exit 0 fi EST=$(( (TOTAL / PARALLEL) * TIMEOUT )) info "Checking originals (~${EST}s estimated)..." echo check_url() { local resized="$1" local log="$2" local timeout="$3" local lockfile="${log}.lock" local original original=$(echo "$resized" | sed -E 's/-[0-9]+x[0-9]+(\.(jpg|jpeg|png|webp))/\1/') local code # Follow redirects (-L) so we resolve 301/302 to a final status code=$(curl -s -o /dev/null -w "%{http_code}" \ --max-time "$timeout" \ --head \ -L \ --retry 1 --retry-delay 1 \ "$original" 2>/dev/null || echo "000") # Fall back to GET range if server blocks HEAD if [[ "$code" == "405" || "$code" == "403" || "$code" == "000" ]]; then code=$(curl -s -o /dev/null -w "%{http_code}" \ --max-time "$timeout" \ -L \ -H "Range: bytes=0-0" \ "$original" 2>/dev/null || echo "000") fi local result if [[ "$code" == "200" || "$code" == "206" ]]; then result="OK|$resized|$original" else result="MISS|$resized|$original ($code)" fi # Atomic write with flock to prevent race conditions from parallel workers ( flock -x 200 echo "$result" >> "$log" ) 200>"$lockfile" } export -f check_url # Feed via cat to avoid xargs -a flag (not supported on all systems) cat "$URLS" | xargs -P "$PARALLEL" -I{} bash -c 'check_url "$@"' _ {} "$LOG" "$TIMEOUT" & WORKER_PID=$! while kill -0 "$WORKER_PID" 2>/dev/null; do DONE=$(grep -c '^\(OK\|MISS\)' "$LOG" 2>/dev/null || true) printf "\r Progress: %d / %d" "$DONE" "$TOTAL" >&2 sleep 0.3 done wait "$WORKER_PID" 2>/dev/null || true DONE=$(grep -c '^\(OK\|MISS\)' "$LOG" 2>/dev/null || true) printf "\r Progress: %d / %d\n" "$DONE" "$TOTAL" >&2 OK_COUNT=$(grep -c '^OK' "$LOG" || true) MISS_COUNT=$(grep -c '^MISS' "$LOG" || true) echo echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" success "$OK_COUNT images ready to fix" [[ "$MISS_COUNT" -gt 0 ]] && warn "$MISS_COUNT originals not found — will be skipped" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" if [[ "$MISS_COUNT" -gt 0 ]]; then echo info "Skipped URLs:" grep '^MISS' "$LOG" | cut -d'|' -f2 fi echo info "To apply fixes run: ./wp-image-fixer.sh --apply" echo exit 0fi# ------------------------------------------------------------------------------# APPLY — fast single-pass Perl replacement# ------------------------------------------------------------------------------INPUT=$(grep '^FILE|' "$LOG" | cut -d'|' -f2)OUTPUT="fixed-$(basename "$INPUT")"echoinfo "Applying fixes to $INPUT..."grep '^OK' "$LOG" | awk -F'|' '{print $2 "\t" $3}' > "$MAP"COUNT=$(wc -l < "$MAP" | tr -d ' ')if [[ "$COUNT" -eq 0 ]]; then warn "No fixes to apply." rm -f "$MAP" "$LOG" exit 0fiREPLACED=$(perl - "$INPUT" "$MAP" "$OUTPUT" <<'PERL'use strict;use warnings;my ($infile, $mapfile, $outfile) = @ARGV;open(my $mfh, '<', $mapfile) or die "Cannot open map: $!";my %map;while (<$mfh>) { chomp; my ($from, $to) = split(/\t/, $_, 2); $map{$from} = $to if defined $from && defined $to;}close($mfh);my $pattern = join('|', map { quotemeta($_) } sort { length($b) <=> length($a) } keys %map);my $regex = qr/$pattern/;open(my $in, '<', $infile) or die "Cannot open input: $!";open(my $out, '>', $outfile) or die "Cannot open output: $!";my %seen;while (my $line = <$in>) { $line =~ s/($regex)/do { $seen{$1} = 1; $map{$1} }/ge; print $out $line;}close($in);close($out);print scalar keys %seen, "\n";PERL)rm -f "$MAP" "$LOG"echoecho "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"success "$REPLACED image URLs replaced — saved as $OUTPUT"echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"echoCấp quyền thực thi cho file vừa tạo
chmod +x wp-image-fixer.shChạy lệnh sau để kiểm tra danh sách có bao nhiêu file cần chỉnh sửa
./wp-image-fixer.shNhập vào tên file xml để xử lý và ngồi đợi khoảng vài phút để hệ thống xử lý, tùy thuộc vào số lượng hình ảnh đang có trên blog.
Mục đích của file này là để kiểm tra xem file gốc filename.jpg có tồn tại không. Nhiều trường hợp file gốc không còn trên server, việc đổi tên sẽ khiến link đến file không tồn tại
Kết quả như sau: có 43 link không cần cập nhật vì file gốc không tồn tại
Enter WordPress XML export file: balampdplo.WordPress.2026-03-10.xml[info] Extracting resized image URLs...[info] Found 1782 resized image URLs[info] Checking originals (~175s estimated)... Progress: 1782 / 1782━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[ok] 1739 images ready to fix[warn] 43 originals not found — will be skipped━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[info] Skipped URLs:[info] To apply fixes run: ./wp-image-fixer.sh --applyCần chạy lệnh trên thêm một lần nữa với tham số --apply để áp dụng những thay đổi này lên file xml. Bước đầu tiên chỉ để kiểm tra và tạo log, bước apply này sẽ tạo ra một file mới với cái link hình ảnh đã được cập nhập.
./wp-image-fixer.sh --applyXác nhận y và đợi vài giây là xong
[info] Existing scan found for: balampdplo.WordPress.2026-03-08.xmlApply fixes using cached results? (y/N): y[info] Applying fixes to balampdplo.WordPress.2026-03-08.xml...━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[ok] 1739 image URLs replaced — saved as fixed-balampdplo.WordPress.2026-03-08.xml━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━File xml sau khi chỉnh sửa sẽ có tên fixed-xxxx.xml sẵn sàng được chuyển đổi qua Markdown ở bước kế tiếp
4. Chuyển đổi XML sang Markdown
Để chuyển đổi file XML sang Markdown (*.md), mình sử dụng công cụ wordpress-export-to-markdown.
npx wordpress-export-to-markdown \ --prefix-date=true \ --post-folders=false \ --frontmatter-fields=title,author,date:pubDatetime,categories,tags,coverImage:image,draft,slug \ --save-images=all \ --date-folders=noneLưu ý: Với 100+ bài, quá trình download ảnh mất 10–20 phút. Không đóng Terminal trong khi xử lý.
Toàn bộ nội dung sẽ được lưu trong thư mục output/:
-
Tất cả nội dung của log được lưu dưới định dạng
.mdtrong thư mục conposts -
Các trang (pages) được lưu trong thư mục
pages -
Các nội dung khác sẽ nằm trong
custom -
Thư mục
images/chứa toàn bộ ảnh
output├── custom│ ├── advanced_ads│ │ └── images│ ├── cp_popups│ ├── foogallery│ ├── google_maps│ ├── surl│ ├── tablepress_table│ └── wpcf7_contact_form├── pages│ ├── _drafts│ └── images└── posts └── images5. Dọn dẹp ảnh, Gutenberg block, shortcode,…
Đây là bước tốn nhiều thời gian nhất. Tùy thuộc vào số lượng bài viết, cấu trúc nội dung bài viết mà cần phải tùy biến cho phù hợp.
Đầu tiên, mình kiểm tra một số file md thì thấy còn khá nhiều link hình ảnh vẫn còn liên kết đến ảnh gốc dạng https://…., ví dụ
](https://balodeplao.com/wp-content/uploads/2017/04/sansai-ryori-bldl01.jpg)Mình chạy thử lệnh này để kiểm tra xem bao nhiêu file gặp tình trạng này
grep -rn 'wp-content' output/posts/ --include='*.md'Kết quả trả về quá nhiều nên không thể chỉnh sửa thủ công được. Dùng lệnh này để xử lý toàn bộ
find output/posts/ -name '*.md' -exec perl -i -pe 's/\[!\[.*?\]\(([^)]+)\)\]\((https?:)?\/\/[^)]*wp-content\/uploads[^)]*\)//g;s/\[\]\((https?:)?\/\/[^)]*wp-content\/uploads[^)]*\)//g;' {} +Lệnh này sẽ cập nhật tất cả các link dạng [](https://site.com/image.jpg) thành 
Tiếp theo dùng lệnh này để kiểm tra xem các link ảnh trong file md có tồn tại trong thư mục images. Vì nếu ảnh không tồn tại sẽ bị lỗi khi Astro build sau này.
# Find all images referenced in .md files, then check if the file actually existsfind output/posts/ -name "*.md" | while read f; do dir=$(dirname "$f") grep -oE '!\[[^]]*\]\(/images/[^)]+\)' "$f" | grep -oE 'images/[^)]+' | while read img; do if [ ! -f "$dir/$img" ]; then echo "MISSING: $dir/$img (in $f)" fi donedoneKết quả
MISSING: /home/mcj/test_folder/output/posts/_drafts/images/q (in /home/mcj/test_folder/output/posts/_drafts/id-4313.md)MISSING: /home/mcj/test_folder/output/posts/_drafts/images/ir (in /home/mcj/test_folder/output/posts/_drafts/id-4313.md)MISSING: /home/mcj/test_folder/output/posts/images/credit-card-statement-620x387.jpg (in /home/mcj/test_folder/output/posts/2015-07-10-lam-gi-khi-the-tin-dung-cua-ban-bi-hack.md)MISSING: /home/mcj/test_folder/output/posts/images/ubersuv-voi-gia-uberBLACK.jpg (in /home/mcj/test_folder/output/posts/2015-12-07-ubersuv-nhieu-cho-hon-gia-khong-doi.md)Để xử lý, mình sẽ tải thủ công các file này về hoặc xóa link trong file markdown tương ứng để tránh gặp lỗi khi Astro compile.
Tiếp theo sẽ cần phải dọn dẹp các hình ảnh hồi xa xưa còn dùng shortcode caption, và xóa các dòng comment liên quan đến Guterberg.
Chạy thử kiểm tra
bash << 'EOF'cd output/postsfixed=0for file in *.md; do original=$(cat "$file") content=$(perl -0777 -pe ' s/\\\[caption\b[^\[]*?\\\]\s*(!\[[^\]]*\]\([^)]*\))\s*(.*?)\s*\\\[\/caption\\\]/ my $img = $1; my $cap = $2; $cap =~ s|^\s+||; $cap =~ s|\s+$||; $img =~ m|!\[([^\]]*)\]\(([^)]*)\)|; my $alt = $1; my $src = $2; $cap ? "" : "" /gxse; s/<!--\s*wp:[^>]+-->//g; s/<!--\s*\/wp:[^>]+-->//g; ' "$file") content=$(printf '%s' "$content" | perl -0777 -pe 's/\n{3,}/\n\n/g') if [ "$content" != "$original" ]; then echo "Would fix: $file" ((fixed++)) fidoneecho ""echo "Total: $fixed files would be fixed"EOFChạy thiệt để chỉnh sửa
bash << 'EOF'cd output/postsfixed=0for file in *.md; do original=$(cat "$file") content=$(perl -0777 -pe ' s/\\\[caption\b[^\[]*?\\\]\s*(!\[[^\]]*\]\([^)]*\))\s*(.*?)\s*\\\[\/caption\\\]/ my $img = $1; my $cap = $2; $cap =~ s|^\s+||; $cap =~ s|\s+$||; $img =~ m|!\[([^\]]*)\]\(([^)]*)\)|; my $alt = $1; my $src = $2; $cap ? "" : "" /gxse; s/<!--\s*wp:[^>]+-->//g; s/<!--\s*\/wp:[^>]+-->//g; ' "$file") content=$(printf '%s' "$content" | perl -0777 -pe 's/\n{3,}/\n\n/g') if [ "$content" != "$original" ]; then printf '%s\n' "$content" > "$file" echo "✅ Fixed: $file" ((fixed++)) fidoneecho ""echo "✨ Done! Fixed $fixed files."EOF6. Xóa ảnh không sử dụng
Mình cũng sẽ kiểm tra xem có file ảnh nào nằm trong thư mục images nhưng không sử dụng (không xuất hiện trong bất kỳ file md nào).
Kiểm tra thử xem có bao nhiêu file ảnh không sử dụng
bash << 'EOF'cd output/posts{ grep -ohE 'images/[^)]+' *.md grep -ohE '^coverImage:\s*"[^"]*"' *.md | grep -ohE '[^/"]+\.(jpg|jpeg|png|webp|gif)' | sed 's/^/images\//'} | sort -u > .used_images.tmpunused=0while read img; do rel="images/$(basename "$img")" if ! grep -qF "$rel" .used_images.tmp; then echo "UNUSED: $img" ((unused++)) fidone < <(find images/ -type f)rm .used_images.tmpecho "---"echo "Total unused: $unused"EOFKết quả
UNUSED: images/Nusa-Dua-BLDL-17.jpgUNUSED: images/Flower-Dome-07.jpgUNUSED: images/Cloud-Forest-09.jpgUNUSED: images/Cloud-Forest-08.jpgUNUSED: images/Flower-Dome-06.jpgUNUSED: images/Wings-of-Time06.jpgUNUSED: images/Nusa-Dua-BLDL-16.jpg---Total unused: 60Xóa các file không sử dụng bằng lệnh này
bash << 'EOF'cd output/posts{ grep -ohE 'images/[^)]+' *.md grep -ohE '^coverImage:\s*"[^"]*"' *.md | grep -ohE '[^/"]+\.(jpg|jpeg|png|webp|gif)' | sed 's/^/images\//'} | sort -u > .used_images.tmpdeleted=0while read img; do rel="images/$(basename "$img")" if ! grep -qF "$rel" .used_images.tmp; then echo "DELETED: $img" rm "$img" ((deleted++)) fidone < <(find images/ -type f)rm .used_images.tmpecho "---"echo "Total deleted: $deleted"EOFVậy là xong Giai đoạn 1. Tất cả bài viết trên WordPress đã được chuyển đổi thành công qua file markdown, sẵn sàng để dọn nhà qua Astro.
Hẹn gặp lại [Phần 2] Cài đặt và cấu hình Astro