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.
Mục Lục
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 Pages
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-migration
mv ~/Downloads/*.xml ~/blog-migration/
cd ~/blog-migrationCode language: Nginx (nginx)3. 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
# ==============================================================================
# wp-image-fixer.sh
# 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 pipefail
LOG=".wp-image-check.log"
URLS=".wp-image-urls.tmp"
MAP=".wp-image-map.txt"
APPLY=false
PARALLEL=50
TIMEOUT=5
[[ "${1:-}" == "--apply" ]] && APPLY=true
info() { echo "[info] $*"; }
success() { echo "[ok] $*"; }
warn() { echo "[warn] $*"; }
die() { echo "[error] $*" >&2; exit 1; }
# ------------------------------------------------------------------------------
# Cleanup on exit or Ctrl+C
# ------------------------------------------------------------------------------
cleanup() {
rm -f "$URLS"
kill 0 2>/dev/null || true
}
trap cleanup INT TERM
trap 'rm -f "$URLS"' 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 0
else
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 original
original=$(echo "$resized" | sed -E 's/-[0-9]+x[0-9]+(\.(jpg|jpeg|png|webp))/\1/')
local code
code=$(curl -s -o /dev/null -w "%{http_code}" \
--max-time "$timeout" \
--head \
--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" \
-H "Range: bytes=0-0" \
"$original" 2>/dev/null || echo "000")
fi
if [[ "$code" == "200" || "$code" == "206" || "$code" == "301" || "$code" == "302" ]]; then
echo "OK|$resized|$original" >> "$log"
else
echo "MISS|$resized|$original ($code)" >> "$log"
fi
}
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 0
fi
# ------------------------------------------------------------------------------
# APPLY — fast single-pass Perl replacement
# ------------------------------------------------------------------------------
INPUT=$(grep '^FILE|' "$LOG" | cut -d'|' -f2)
OUTPUT="fixed-$(basename "$INPUT")"
echo
info "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 0
fi
REPLACED=$(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"
echo
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
success "$REPLACED image URLs replaced — saved as $OUTPUT"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echoCode language: Bash (bash)Cấp quyền thực thi cho file vừa tạo
chmod +x wp-image-fixer.shCode language: CSS (css)Chạ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 --applyCode language: YAML (yaml)Cầ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.xml
Apply 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
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━Code language: YAML (yaml)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:pubDate,categories,tags,coverImage,draft,slug \
--save-images=all \
--date-folders=noneCode language: Nginx (nginx)Lư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
└── imagesCode language: Nginx (nginx)5. 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)Code language: Markdown (markdown)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'Code language: PHP (php)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;
' {} +Code language: Markdown (markdown)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 exists
find 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
done
doneCode language: Bash (bash)Kế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)Code language: JavaScript (javascript)Để 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/posts
fixed=0
for 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++))
fi
done
echo ""
echo "Total: $fixed files would be fixed"
EOFCode language: Nginx (nginx)Chạy thiệt để chỉnh sửa
bash << 'EOF'
cd output/posts
fixed=0
for 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++))
fi
done
echo ""
echo "✨ Done! Fixed $fixed files."
EOFCode language: Nginx (nginx)6. 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.tmp
unused=0
while read img; do
rel="images/$(basename "$img")"
if ! grep -qF "$rel" .used_images.tmp; then
echo "UNUSED: $img"
((unused++))
fi
done < <(find images/ -type f)
rm .used_images.tmp
echo "---"
echo "Total unused: $unused"
EOFCode language: Bash (bash)Kết quả
UNUSED: images/Nusa-Dua-BLDL-17.jpg
UNUSED: images/Flower-Dome-07.jpg
UNUSED: images/Cloud-Forest-09.jpg
UNUSED: images/Cloud-Forest-08.jpg
UNUSED: images/Flower-Dome-06.jpg
UNUSED: images/Wings-of-Time06.jpg
UNUSED: images/Nusa-Dua-BLDL-16.jpg
---
Total unused: 60Code language: YAML (yaml)Xó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.tmp
deleted=0
while read img; do
rel="images/$(basename "$img")"
if ! grep -qF "$rel" .used_images.tmp; then
echo "DELETED: $img"
rm "$img"
((deleted++))
fi
done < <(find images/ -type f)
rm .used_images.tmp
echo "---"
echo "Total deleted: $deleted"
EOFCode language: Bash (bash)Vậ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






