Vấn đề ban đầu

Cần tạo một workflow GitHub Action để tự động thêm hoặc xóa property share_link trong frontmatter của các file markdown, với cấu trúc URL https://domain.com/<đường-dẫn-file> (không dấu tiếng Việt, không đuôi .md) cho những file có property publish: true.

Tổng quan về Workflow

Workflow này sẽ:

  1. Được kích hoạt khi có thay đổi trong các file Markdown hoặc khi được kích hoạt thủ công
  2. Quét tất cả các file Markdown trong repository, không giới hạn độ sâu thư mục
  3. Cập nhật hoặc thêm thuộc tính share_link trong frontmatter của mỗi file dựa trên đường dẫn và tên file
  4. Tự động commit các thay đổi vào repository

Chi tiết Workflow

GitHub Action workflow hoàn chỉnh gồm:

1. File workflow: .github/workflows/update-share-links.yml

name: Update Share Links
 
on:
  push:
    branches: [ main, master ]
    paths:
      - '**/*.md'  # Chỉ kích hoạt khi có thay đổi ở file markdown
  workflow_dispatch:  # Cho phép kích hoạt thủ công
 
jobs:
  update-share-links:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Lấy toàn bộ lịch sử git
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '18'
 
      - name: Install dependencies
        run: npm install gray-matter fs path glob
 
      - name: Install dependencies glob @vnodesign/slugify
        run: npm install gray-matter glob @vnodesign/slugify
 
      - name: Update share links
        run: node .github/scripts/update-share-links.js
        env:
          DOMAIN: ${{ secrets.DOMAIN }}
 
      - name: Commit changes
        uses: stefanzweifel/git-auto-commit-action@v5
        with:
          commit_message: "chore: Update share_link properties"
          file_pattern: '**/*.md'
          commit_user_name: "github-actions[bot]"
          commit_user_email: "github-actions[bot]@users.noreply.github.com"
          disable_globbing: true
          push_options: "--force"
          commit_author: "hoangphuctran93 <[email protected]>"
 
      - name: Check commit status
        run: |
          if git log -1 --pretty=%B | grep -q "Update share_link properties"; then
            echo "✅ Share links updated successfully"
          else
            echo "ℹ️ No changes were needed"
          fi

Phân tích chi tiết

1. Cấu hình Trigger
on:
  push:
    branches: [ main, master ]
    paths:
      - '**/*.md'  # Chỉ kích hoạt khi có thay đổi ở file markdown
  workflow_dispatch:  # Cho phép kích hoạt thủ công
 
  • push: Workflow sẽ được kích hoạt khi có commit đẩy lên nhánh main hoặc master
  • paths: Giới hạn việc kích hoạt chỉ khi có thay đổi trong các file .md
  • workflow_dispatch: Cho phép kích hoạt workflow thủ công từ giao diện GitHub Cấu hình này giúp tối ưu hiệu suất bằng cách chỉ chạy workflow khi cần thiết, tránh chạy không cần thiết khi có thay đổi ở các file không phải Markdown.
2. Cấu hình Job và Permissions
jobs:
  update-share-links:
    runs-on: ubuntu-latest
    permissions:
      contents: write
  • runs-on: Chỉ định job chạy trên môi trường Ubuntu mới nhất
  • permissions: Cấp quyền ghi nội dung để workflow có thể commit thay đổi vào repository
3. Checkout Repository
- name: Checkout repository
  uses: actions/checkout@v4
  with:
    fetch-depth: 0  # Lấy toàn bộ lịch sử git
  • Sử dụng actions/checkout@v4 để clone repository vào runner
  • fetch-depth: 0 đảm bảo toàn bộ lịch sử git được tải về, cần thiết cho việc commit và push thay đổi
4. Cài đặt Node.js
- name: Setup Node.js
  uses: actions/setup-node@v4
  with:
    node-version: '18'
  • Cài đặt Node.js phiên bản 18 lên runner
  • Node.js là cần thiết để chạy script JavaScript xử lý các file Markdown
5. Cài đặt Dependencies
- name: Install dependencies
  run: npm install gray-matter fs path glob
 
- name: Install dependencies glob @vnodesign/slugify
  run: npm install gray-matter glob @vnodesign/slugify

Cài đặt các thư viện JavaScript cần thiết:

  • gray-matter: Phân tích và thao tác với frontmatter trong file Markdown
  • fs và path: Thư viện chuẩn của Node.js để thao tác với hệ thống file
  • glob: Tìm kiếm file theo mẫu, hỗ trợ tìm tất cả file Markdown trong repository
  • @vnodesign/slugify: Chuyển đổi text thành slug URL-friendly
- name: Update share links
  run: node .github/scripts/update-share-links.js
  env:
    DOMAIN: ${{ secrets.DOMAIN }}
  • Chạy script JavaScript .github/scripts/update-share-links.js để xử lý các file Markdown
  • Truyền biến môi trường DOMAIN từ GitHub Secrets để script có thể tạo URL đầy đủ
7. Commit Thay đổi
- name: Commit changes
  uses: stefanzweifel/git-auto-commit-action@v5
  with:
    commit_message: "chore: Update share_link properties"
    file_pattern: '**/*.md'
    commit_user_name: "github-actions[bot]"
    commit_user_email: "github-actions[bot]@users.noreply.github.com"
    disable_globbing: true
    push_options: "--force"
    commit_author: "hoangphuctran93 <[email protected]>"
  • Sử dụng action stefanzweifel/git-auto-commit-action@v5 để tự động commit thay đổi
  • commit_message: Thiết lập thông điệp commit
  • file_pattern'**/*.md' bắt tất cả file Markdown ở mọi cấp độ thư mục
  • disable_globbingtrue ngăn việc mở rộng glob trước khi gửi đến git, đảm bảo mẫu đường dẫn được xử lý chính xác
  • push_options--force đảm bảo thay đổi được đẩy lên ngay cả khi có xung đột
  • commit_author: Thiết lập tác giả commit
8. Kiểm tra Trạng thái Commit
- name: Check commit status
  run: |
    if git log -1 --pretty=%B | grep -q "Update share_link properties"; then
      echo "✅ Share links updated successfully"
    else
      echo "ℹ️ No changes were needed"
    fi
  • Kiểm tra xem commit đã được tạo thành công chưa bằng cách tìm kiếm thông điệp commit
  • Hiển thị thông báo phù hợp để dễ dàng theo dõi kết quả

2. File script: .github/scripts/update-share-links.js

const fs = require('fs');
const path = require('path');
const matter = require('gray-matter');
const glob = require('glob');
 
// Lấy domain từ biến môi trường hoặc sử dụng giá trị mặc định
const DOMAIN = process.env.DOMAIN || 'https://kb.agentc.asia';
 
// Hàm chuyển đổi tiếng Việt có dấu thành không dấu
function customSlugify(str) {
  if (!str) return '';
  
  return str
    .normalize('NFD') // Tách dấu tiếng Việt
    .replace(/[\u0300-\u036f]/g, '') // Xóa dấu phụ
    .replace(/[đĐ]/g, match => match === 'đ' ? 'd' : 'D') // Xử lý đ/Đ
    .toLowerCase() // Chuyển thành chữ thường
    .replace(/&/g, '-and-') // Thay thế ký tự '&'
    .replace(/%/g, '-percent') // Thay thế ký tự '%'
    replace(/\+/g, '-plus-') // Thay thế ký tự '+'
    .replace(/\s+/g, '-') // Thay thế khoảng trắng bằng gạch ngang
    .replace(/[^\w\-\/]/g, '') // Loại bỏ ký tự không phải chữ cái, số, gạch ngang hoặc dấu /
    .replace(/\-{2,}/g, '-') // Thay thế nhiều dấu gạch ngang liên tiếp
    .split('/')
    .map(segment => segment.replace(/^-+|-+$/g, '')) // Loại bỏ gạch ngang ở đầu và cuối mỗi phân đoạn
    .filter(Boolean) // Loại bỏ phân đoạn rỗng
    .join('/') // Kết hợp các phân đoạn bằng dấu /
    .replace(/\/$/, ''); // Loại bỏ dấu / cuối cùng
}
 
const processPath = (filePath) => {
  // Xử lý đường dẫn file thành URL slug
  const dirPath = path.dirname(filePath);
  const fileName = path.basename(filePath, path.extname(filePath));
  
  // Xử lý từng phần của đường dẫn
  const slugParts = dirPath === '.' 
    ? [customSlugify(fileName)] 
    : [...dirPath.split(/[\/\\]/).map(customSlugify), customSlugify(fileName)];
  
  return slugParts.filter(Boolean).join('/');
};
 
// Đếm số lượng thay đổi để báo cáo
let updatedCount = 0;
let removedCount = 0;
let errorCount = 0;
 
console.log('🔍 Bắt đầu quét các file markdown...');
 
// Quét tất cả file .md từ thư mục gốc
glob.sync('**/*.md', { 
  ignore: ['**/node_modules/**', '**/.github/**', '**/dist/**', '**/build/**'],
  nodir: true
}).forEach(file => {
  const absolutePath = path.resolve(file);
  
  try {
    const fileContent = fs.readFileSync(absolutePath, 'utf8');
    const { data, content } = matter(fileContent);
    let isModified = false;
    
    if (data.publish === true) {
      // Xử lý khi publish: true
      const cleanPath = processPath(file);
      const newShareLink = `${DOMAIN}/${cleanPath}`;
      
      // Chỉ cập nhật nếu share_link khác
      if (data.share_link !== newShareLink) {
        data.share_link = newShareLink;
        isModified = true;
        updatedCount++;
        console.log(`✅ Đã cập nhật: ${file} → ${cleanPath}`);
      }
    } else {
      // Xử lý khi publish không phải true
      if (data.hasOwnProperty('share_link')) {
        delete data.share_link; // Xóa property nếu tồn tại
        isModified = true;
        removedCount++;
        console.log(`🗑️ Đã xóa share_link từ: ${file}`);
      }
    }
    
    // Chỉ ghi lại file nếu có thay đổi
    if (isModified) {
      fs.writeFileSync(absolutePath, matter.stringify(content, data), 'utf8');
    }
  } catch (error) {
    console.error(`❌ Lỗi xử lý ${file}:`, error.message);
    errorCount++;
  }
});
 
console.log(`\n📊 Tổng kết:
- Đã cập nhật: ${updatedCount} file
- Đã xóa share_link: ${removedCount} file
- Lỗi: ${errorCount} file`);
 
if (updatedCount === 0 && removedCount === 0) {
  console.log('ℹ️ Không có thay đổi nào được thực hiện.');
}

Phân tích chi tiết

1. Thiết lập và Import Dependencies
const fs = require('fs');           // Thư viện xử lý file system
const path = require('path');       // Thư viện xử lý đường dẫn
const matter = require('gray-matter'); // Thư viện xử lý frontmatter
const glob = require('glob');       // Thư viện tìm kiếm file theo mẫu

Script sử dụng các thư viện chuẩn của Node.js và các thư viện bổ sung để:

  • Đọc/ghi file (fs)
  • Xử lý đường dẫn (path)
  • Phân tích và thao tác với frontmatter trong file Markdown (gray-matter)
  • Tìm kiếm file theo mẫu glob (glob)

2. Cấu hình Domain

const DOMAIN = process.env.DOMAIN || 'https://kb.agentc.asia';

Script lấy domain từ biến môi trường (được thiết lập trong workflow) hoặc sử dụng giá trị mặc định nếu không có. Domain này sẽ được sử dụng để tạo URL đầy đủ cho thuộc tính share_link.

3. Hàm Slugify Tùy Chỉnh
function customSlugify(str) {
  if (!str) return '';
  
  return str
    .normalize('NFD')
    .replace(/[\u0300-\u036f]/g, '')
    .replace(/[đĐ]/g, match => match === 'đ' ? 'd' : 'D')
    .toLowerCase()
    .replace(/&/g, '-and-')
    .replace(/%/g, '-percent')
    .replace(/\+/g, '-plus-')
    .replace(/\s+/g, '-')
    .replace(/[^\w\-\/]/g, '')
    .replace(/\-{2,}/g, '-')
    .split('/')
    .map(segment => segment.replace(/^-+|-+$/g, ''))
    .filter(Boolean)
    .join('/')
    .replace(/\/$/, '');
}

Đây là hàm đặc biệt quan trọng để chuyển đổi text thành slug URL-friendly:

  1. .normalize('NFD') và .replace(/[\u0300-\u036f]/g, ''): Chuyển đổi các ký tự tiếng Việt có dấu thành không dấu
  2. .replace(/[đĐ]/g, match => match === 'đ' ? 'd' : 'D'): Xử lý riêng cho ký tự ‘đ/Đ’
  3. .toLowerCase(): Chuyển tất cả thành chữ thường
  4. Các bước tiếp theo thay thế các ký tự đặc biệt:
    • & thành -and-
    • % thành -percent
    • + thành -plus-
    • Khoảng trắng thành dấu gạch ngang
  5. Loại bỏ các ký tự không phải chữ cái, số, gạch ngang hoặc dấu gạch chéo
  6. Xử lý các trường hợp có nhiều dấu gạch ngang liên tiếp
  7. Xử lý từng phần của đường dẫn (nếu có dấu /)
  8. Loại bỏ dấu gạch ngang ở đầu và cuối mỗi phần
  9. Loại bỏ dấu / ở cuối đường dẫn

4. Hàm Xử Lý Đường Dẫn

const processPath = (filePath) => {
  // Xử lý đường dẫn file thành URL slug
  const dirPath = path.dirname(filePath);
  const fileName = path.basename(filePath, path.extname(filePath));
  
  // Xử lý từng phần của đường dẫn
  const slugParts = dirPath === '.' 
    ? [customSlugify(fileName)] 
    : [...dirPath.split(/[\/\\]/).map(customSlugify), customSlugify(fileName)];
  
  return slugParts.filter(Boolean).join('/');
};

Hàm này chuyển đổi đường dẫn file thành URL slug:

  1. Tách đường dẫn thành phần thư mục (dirPath) và tên file (fileName)
  2. Nếu file nằm ở thư mục gốc (dirPath === '.'), chỉ sử dụng tên file
  3. Nếu không, tách đường dẫn thư mục thành các phần và áp dụng hàm customSlugify cho mỗi phần
  4. Kết hợp các phần đã xử lý thành đường dẫn URL
5. Quét và Xử Lý File Markdown
// Đếm số lượng thay đổi để báo cáo
let updatedCount = 0;
let removedCount = 0;
let errorCount = 0;
 
console.log('🔍 Bắt đầu quét các file markdown...');
 
// Quét tất cả file .md từ thư mục gốc
glob.sync('**/*.md', { 
  ignore: ['**/node_modules/**', '**/.github/**', '**/dist/**', '**/build/**'],
  nodir: true
}).forEach(file => {
  // Xử lý từng file
});

Script sử dụng glob.sync để tìm tất cả file .md trong repository, với một số loại trừ quan trọng:

  • **/node_modules/**: Bỏ qua thư mục node_modules
  • **/.github/**: Bỏ qua thư mục .github
  • **/dist/****/build/**: Bỏ qua các thư mục build
6. Xử Lý Từng File Markdown
glob.sync('**/*.md', { /* ... */ }).forEach(file => {
  const absolutePath = path.resolve(file);
  
  try {
    const fileContent = fs.readFileSync(absolutePath, 'utf8');
    const { data, content } = matter(fileContent);
    let isModified = false;
    
    if (data.publish === true) {
      // Xử lý khi publish: true
      const cleanPath = processPath(file);
      const newShareLink = `${DOMAIN}/${cleanPath}`;
      
      // Chỉ cập nhật nếu share_link khác
      if (data.share_link !== newShareLink) {
        data.share_link = newShareLink;
        isModified = true;
        updatedCount++;
        console.log(`✅ Đã cập nhật: ${file} → ${cleanPath}`);
      }
    } else {
      // Xử lý khi publish không phải true
      if (data.hasOwnProperty('share_link')) {
        delete data.share_link; // Xóa property nếu tồn tại
        isModified = true;
        removedCount++;
        console.log(`🗑️ Đã xóa share_link từ: ${file}`);
      }
    }
    
    // Chỉ ghi lại file nếu có thay đổi
    if (isModified) {
      fs.writeFileSync(absolutePath, matter.stringify(content, data), 'utf8');
    }
  } catch (error) {
    console.error(`❌ Lỗi xử lý ${file}:`, error.message);
    errorCount++;
  }
});

Đây là phần quan trọng nhất của script, xử lý từng file Markdown:

  1. Đọc nội dung file và phân tích frontmatter với gray-matter
  2. Kiểm tra thuộc tính publish trong frontmatter:
    • Nếu publish === true:
      • Tạo đường dẫn sạch từ đường dẫn file
      • Tạo share_link mới bằng cách kết hợp domain và đường dẫn sạch
      • Cập nhật share_link nếu khác với giá trị hiện tại
    • Nếu publish !== true:
      • Xóa thuộc tính share_link nếu tồn tại
  3. Chỉ ghi lại file nếu có thay đổi
  4. Xử lý lỗi và đếm số lượng file được cập nhật, xóa hoặc gặp lỗi
7. Báo Cáo Kết Quả
console.log(`\n📊 Tổng kết:
- Đã cập nhật: ${updatedCount} file
- Đã xóa share_link: ${removedCount} file
- Lỗi: ${errorCount} file`);
 
if (updatedCount === 0 && removedCount === 0) {
  console.log('ℹ️ Không có thay đổi nào được thực hiện.');
}

Cuối cùng, script hiển thị báo cáo tổng kết về số lượng file đã được cập nhật, xóa hoặc gặp lỗi.

Các tính năng chính

  1. Xử lý tiếng Việt: Chuyển đổi tiếng Việt có dấu thành không dấu (ví dụ: “Hướng dẫn” → “huong-dan”)
  2. Xử lý đường dẫn: Thay thế khoảng trắng bằng dấu gạch ngang, loại bỏ ký tự đặc biệt
  3. Loại bỏ đuôi .md: Tạo URL không chứa đuôi file
  4. Quét file hiệu quả: Xử lý cả file ở thư mục gốc và thư mục con
  5. Xóa property: Tự động xóa share_link nếu publish không phải true

Ví dụ kết quả

  • File: Hướng dẫn/Cài đặt hệ thống.md với publish: true

    • Kết quả: share_link: "https://kb.agentc.asia/huong-dan/cai-dat-he-thong"
  • File: Đặc biệt & Khó.md với publish: true

    • Kết quả: share_link: "https://kb.agentc.asia/dac-biet-and-kho"
  • File: Bài viết.md với publish: false

    • Kết quả: Property share_link bị xóa

Cách sử dụng

  1. Thêm các file trên vào repository
  2. Đảm bảo các file markdown có property publish: true nếu muốn tạo share_link
  3. Push lên GitHub hoặc chạy workflow thủ công
  4. Workflow sẽ tự động cập nhật các file và commit thay đổi

Tùy chỉnh

  • Thay đổi domain bằng cách sửa biến DOMAIN trong script
  • Điều chỉnh quy tắc chuyển đổi URL bằng cách sửa hàm customSlugify
  • Thay đổi pattern file bằng cách sửa cấu hình `glob.sync

Ứng dụng cho các dự án tương tự

Workflow này có thể dễ dàng điều chỉnh cho nhiều mục đích khác nhau:

  1. Cập nhật các thuộc tính khác trong frontmatter:
    • Thay đổi script để cập nhật các thuộc tính như last_modifiedauthortags, v.v.
    data.last_modified = new Date().toISOString();
    data.word_count = content.split(/\s+/).length;
  2. Tự động tạo Table of Contents:
    • Điều chỉnh script để phân tích nội dung Markdown và tạo mục lục tự động
    const toc = generateTableOfContents(content);
    data.toc = toc;
  3. Kiểm tra và sửa lỗi liên kết:
    • Mở rộng script để kiểm tra các liên kết bị hỏng và cập nhật chúng
    const updatedContent = fixBrokenLinks(content);
  4. Tối ưu hóa hình ảnh:
    • Thêm các bước để tìm, tối ưu và thay thế đường dẫn hình ảnh trong các file Markdown
  5. Đồng bộ hóa metadata:
    • Đảm bảo các thuộc tính như tags, categories được nhất quán trong toàn bộ repository
    const keywords = extractKeywords(content);
    data.tags = keywords;

Tùy chỉnh theo nhu cầu cụ thể

  • Thay đổi điều kiện cập nhật: Thay vì dựa vào publish: true, bạn có thể sử dụng điều kiện khác
    if (data.status === 'published' || data.visibility === 'public') {
    // Cập nhật share_link
    }
  • Tùy chỉnh định dạng URL: Thay đổi cấu trúc URL theo nhu cầu
    const newShareLink = `${DOMAIN}/articles/${data.category}/${cleanPath}`;
- **Thêm các tham số URL**: Thêm tham số theo dõi hoặc UTM
```javascript

	const newShareLink = `${DOMAIN}/${cleanPath}?utm_source=docs`;

Lưu ý khi triển khai

  1. Bảo mật:
    • Luôn sử dụng GitHub Secrets cho domain và các thông tin nhạy cảm
    • Kiểm tra kỹ các quyền của workflow
  2. Hiệu suất:
    • Với repository lớn, cân nhắc chỉ xử lý các file đã thay đổi
    • Có thể thêm caching để tránh cài đặt lại dependencies mỗi lần chạy
  3. Xử lý lỗi:
    • Script đã có xử lý lỗi cơ bản, nhưng có thể cần mở rộng cho các trường hợp đặc biệt
    • Thêm logging chi tiết hơn để dễ dàng debug
  4. Tương thích:
    • Đảm bảo hàm slugify xử lý đúng các ký tự đặc biệt trong ngôn ngữ của bạn
    • Kiểm tra tương thích với các công cụ tạo site tĩnh (nếu có)
  5. Tài liệu: Đảm bảo script và workflow được tài liệu hóa đầy đủ để dễ dàng bảo trì Với workflow này, bạn có thể tự động hóa việc duy trì các thuộc tính metadata trong file Markdown, giúp tiết kiệm thời gian và đảm bảo tính nhất quán trong toàn bộ dự án.

Kết luận

Workflow “Update Share Links” là một ví dụ tuyệt vời về cách tự động hóa quy trình bảo trì tài liệu với GitHub Actions. Bằng cách tự động cập nhật thuộc tính share_link cho tất cả file Markdown, bạn đảm bảo tính nhất quán và tiết kiệm thời gian quản lý thủ công.

Với những điều chỉnh phù hợp, bạn có thể áp dụng mẫu workflow này cho nhiều tác vụ tự động hóa khác trong quy trình phát triển và bảo trì tài liệu của mình.