Tự Động Hóa Dịch Thuật Đa Ngôn Ngữ React với lingui.js và Biến Đổi AST

Đối với các dự án React đã phát triển với hàng trăm trang, việc triển khai quốc tế hóa (i18n) có thể trở thành một thách thức lớn. Việc chuyển đổi thủ công tất cả các chuỗi văn bản sang định dạng hỗ trợ đa ngôn ngữ sẽ tốn rất nhiều công sức và thời gian.

Bối cảnh vấn đề

Hãy tưởng tượng một ứng dụng web đã hoàn thiện với nội dung chủ yếu bằng tiếng Việt. Bây giờ, yêu cầu là phải hỗ trợ thêm tiếng Anh và các ngôn ngữ khác. Các phương pháp i18n truyền thống thường yêu cầu duy trì các tệp từ điển ngôn ngữ, nơi mỗi chuỗi văn bản được gán một khóa duy nhất.

Ví dụ điển hình với react-intl:

import { IntlProvider, FormattedMessage } from 'react-intl';

const messages = {
  en: {
    greeting: 'Hello, {name}!',
  },
  fr: {
    greeting: 'Bonjour, {name}!',
  },
};

const App = () => {
  return (
    <IntlProvider locale="en" messages={messages['en']}>
      <h1><FormattedMessage id="greeting" values={{ name: 'John' }} /></h1>
    </IntlProvider>
  );
};

Nếu dự án được lên kế hoạch i18n từ đầu, cách tiếp cận này khá hợp lý. Tuy nhiên, với một dự án lớn đã có sẵn nội dung tiếng Việt, việc thủ công thay thế hàng ngàn chuỗi văn bản bằng <FormattedMessage id="..." /> hoặc các khóa từ điển sẽ là một cơn ác mộng. Hơn nữa, việc đọc các khóa như id="greeting" không trực quan bằng việc đọc trực tiếp văn bản tiếng Việt.

Do đó, cần tìm một giải pháp hiệu quả hơn để tự động hóa quá trình này.

Nghiên cứu các giải pháp i18n phổ biến

1. react-intl

Là một phần của FormatJS, react-intl là một trong những thư viện i18n mạnh mẽ và phổ biến nhất cho React. Nó cung cấp các công cụ để định dạng ngày, số, tiền tệ và quản lý tin nhắn đa ngôn ngữ.

  • Hỗ trợ định dạng số, ngày giờ, tiền tệ.
  • Khả năng nội suy giá trị vào chuỗi dịch.
  • Quản lý các quy tắc địa phương hóa phức tạp.
npm install react-intl
import { IntlProvider, FormattedMessage } from 'react-intl';

// ... (messages object as above)

const App = () => {
  return (
    <IntlProvider locale="en" messages={messages['en']}>
      <h1><FormattedMessage id="welcome_message" values={{ user: 'Alice' }} /></h1>
    </IntlProvider>
  );
};

2. react-i18next

Được xây dựng trên nền tảng i18next, thư viện này nổi bật với sự linh hoạt, hệ thống plugin phong phú và khả năng tải gói ngôn ngữ động. react-i18next phù hợp cho các ứng dụng cần sự tùy biến cao.

  • Chuyển đổi ngôn ngữ UI tự động.
  • Tải gói ngôn ngữ theo yêu cầu.
  • Hỗ trợ nội suy, định dạng, và các tính năng backend.
  • Hệ sinh thái plugin đa dạng.
npm install react-i18next i18next
import { useTranslation } from 'react-i18next';

// Khởi tạo i18n (ví dụ)
// i18n.use(initReactI18next).init({ ... });

const MyComponent = () => {
  const { t, i18n } = useTranslation();

  return (
    <div>
      <h1>{t('greeting_with_name', { name: 'Peter' })}</h1>
      <button onClick={() => i18n.changeLanguage('de')}>Chuyển sang tiếng Đức</button>
    </div>
  );
};

Cấu hình cơ bản:

import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';

i18n.use(initReactI18next).init({
  resources: {
    en: {
      translation: {
        greeting_with_name: 'Hello, {{name}}!',
      },
    },
    de: {
      translation: {
        greeting_with_name: 'Hallo, {{name}}!',
      },
    },
  },
  lng: 'en', // Ngôn ngữ mặc định
  fallbackLng: 'en', // Ngôn ngữ dự phòng
});

3. lingui.js

lingui.js là một thư viện i18n nhẹ, tập trung vào sự đơn giản, dễ sử dụng và hiệu suất tối ưu. Điểm nổi bật nhất của nó là khả năng tự động trích xuất các khóa dịch từ mã nguồn.

  • Tự động trích xuất chuỗi văn bản cần dịch.
  • Tải các tệp dịch theo nhu cầu (on-demand).
  • Hiệu suất cao, hỗ trợ tối ưu hóa tĩnh.
  • Cung cấp cú pháp dịch thuật trực quan thông qua các macro.
npm install @lingui/react @lingui/core
import { I18nProvider, Trans } from '@lingui/react';
import { i18n } from '@lingui/core';
import enMessages from './locales/en/messages'; // Tệp dịch tiếng Anh đã biên dịch
import viMessages from './locales/vi/messages'; // Tệp dịch tiếng Việt đã biên dịch

i18n.load({
  en: enMessages,
  vi: viMessages,
});

i18n.activate('vi'); // Kích hoạt ngôn ngữ tiếng Việt

const AppComponent = () => {
  return (
    <I18nProvider i18n={i18n}>
      <h1><Trans>Chào mừng đến với ứng dụng!</Trans></h1>
    </I18nProvider>
  );
};

Lựa chọn lingui.js cho tự động hóa

Tính năng "hỗ trợ tự động trích xuất khóa dịch" của lingui.js là yếu tố quyết định cho dự án hiện tại. Thay vì phải thủ công tạo khóa cho từng chuỗi, lingui.js cho phép chúng ta đánh dấu các chuỗi cần dịch bằng cú pháp đặc biệt:

  • Đối với chuỗi trong thuộc tính JSX hoặc trong mã JS: sử dụng t`Chuỗi cần dịch` (tagged template literal).
  • Đối với nội dung văn bản trong các phần tử JSX: sử dụng <Trans>Nội dung cần dịch</Trans>.

Ví dụ mã nguồn React ban đầu:

<div title="Kiểm tra" data-info={getData('Kiểm tra')}>
  Nội dung kiểm tra {getData('Kiểm tra')}
</div>

Sau khi đánh dấu bằng lingui.js:

<div title={t`Kiểm tra`} data-info={getData(t`Kiểm tra`)}>
  <Trans>Nội dung kiểm tra {getData(t`Kiểm tra`)}</Trans>
</div>

Sau đó, chạy lệnh sau để trích xuất các chuỗi đã đánh dấu:

npx lingui extract

Lệnh này sẽ quét mã nguồn và tạo ra các tệp .po (Portable Object) tương ứng cho mỗi ngôn ngữ đã cấu hình. Ví dụ, nếu cấu hình tiếng Việt và tiếng Anh, bạn sẽ có các tệp messages.po cho vien.

Tệp /locales/vi/messages.po (tiếng Việt - ngôn ngữ gốc):

msgid ""
msgstr ""
"POT-Creation-Date: 2024-12-09 18:45+0800\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: vi\n"
// ... các metadata khác

#: src/components/MyComponent.tsx
#: src/utils/helpers.ts
msgid "Kiểm tra"
msgstr "Kiểm tra"

#: src/pages/Test/index.tsx
msgid "Nội dung kiểm tra {getData}"
msgstr "Nội dung kiểm tra {getData}"

Tệp /locales/en/messages.po (tiếng Anh - cần dịch):

msgid ""
msgstr ""
"POT-Creation-Date: 2024-12-09 18:45+0800\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: en\n"
// ... các metadata khác

#: src/components/MyComponent.tsx
#: src/utils/helpers.ts
msgid "Kiểm tra"
msgstr "" // Cần điền bản dịch tiếng Anh vào đây

#: src/pages/Test/index.tsx
msgid "Nội dung kiểm tra {getData}"
msgstr "" // Cần điền bản dịch tiếng Anh vào đây

Giải thích các trường:

  • msgid: Khóa định danh duy nhất cho chuỗi gốc (ở đây là chuỗi tiếng Việt).
  • msgstr: Nội dung dịch tương ứng với msgid cho ngôn ngữ hiện tại.

Bạn có thể thấy rằng trong tệp tiếng Việt, msgstr đã được điền tự động với giá trị của msgid. Trong khi đó, ở tệp tiếng Anh, msgstr vẫn trống, chờ đợi bản dịch thủ công.

Sau khi dịch xong các tệp .po, chạy lệnh sau để biên dịch chúng thành các tệp JavaScript/TypeScript nén mà ứng dụng có thể sử dụng:

npx lingui compile

Ví dụ tệp /locales/vi/messages.ts (đã biên dịch):

/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"f0oTqA\":[\"Kiểm tra\"],\"aX1yZ2\":[\"Nội dung kiểm tra {getData}\"]}")as Messages;

Ví dụ tệp /locales/en/messages.ts (đã biên dịch, giả sử đã dịch):

/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"f0oTqA\":[\"Check\"],\"aX1yZ2\":[\"Test content {getData}\"]}")as Messages;

Tóm lại, quy trình chính là duy trì các tệp .po và sử dụng npx lingui compile để tạo ra các tệp từ điển JavaScript sẵn sàng cho ứng dụng.

Vấn đề đặt ra: Làm sao để tự động đánh dấu?

Việc thủ công tìm và bọc tất cả các chuỗi tiếng Việt bằng t`...` hoặc <Trans>...</Trans> vẫn là một gánh nặng lớn. Chúng ta cần một công cụ tự động thực hiện việc này.

Phân tích yêu cầu cho công cụ tự động hóa

Hãy xem xét ví dụ mã nguồn React ban đầu và kết quả mong muốn:

Mã nguồn gốc:

const ProductDetail = () => {
  const formatValue = (text: string) => {
    return text;
  };
  return (
    <div>
      <div title="Tên sản phẩm" data-value={formatValue('Mô tả sản phẩm')}>
        Giá: {formatValue('100.000 VNĐ')}
      </div>
      <CustomTable header="Danh mục sản phẩm" />
    </div>
  );
};

Mã nguồn sau khi biến đổi:

import { useLingui, Trans } from '@lingui/react/macro';
const ProductDetail = () => {
  const { t } = useLingui(); // Hook được thêm vào
  const formatValue = (text: string) => {
    return text;
  };
  return (
    <div>
      <div title={t`Tên sản phẩm`} data-value={formatValue(t`Mô tả sản phẩm`)}>
        <Trans>Giá: {formatValue(t`100.000 VNĐ`)}</Trans>
      </div>
      <CustomTable header={t`Danh mục sản phẩm`} />
    </div>
  );
};

Từ ví dụ trên, chúng ta rút ra các yêu cầu cho công cụ biến đổi:

  1. Chuyển đổi thuộc tính chuỗi: Biến prop="Chuỗi" thành prop={t`Chuỗi`}.
  2. Chuyển đổi nội dung JSX: Biến <div>Chuỗi</div> thành <div><Trans>Chuỗi</Trans></div>.
  3. Thêm khai báo import: Nếu chưa có, phải thêm import { useLingui, Trans } from '@lingui/react/macro';.
  4. Thêm hook useLingui: Nếu một component functional chưa sử dụng const { t } = useLingui();, phải thêm nó vào đầu hàm component.
  5. Tránh chuyển đổi trùng lặp: Không biến đổi lại nội dung đã được bọc bởi t`...` hoặc <Trans>...</Trans>.
  6. Tránh import trùng lặp: Chỉ thêm các câu lệnh import một lần duy nhất.
  7. Phạm vi hook: Hook useLingui phải được thêm riêng cho từng component functional cần nó, không phải toàn bộ tệp.
  8. Xử lý các loại component: Hỗ trợ hàm component, component mũi tên, forwardRef, và class component.
  9. Xử lý chuỗi trong mã JS thuần: Nếu có chuỗi tiếng Việt trong mã JS (không phải JSX) trong phạm vi component, cũng cần được bọc bằng t`...`.

Giải pháp: Biến đổi Cây cú pháp trừu tượng (AST)

Để tự động thực hiện các chuyển đổi trên, chúng ta có thể sử dụng phương pháp biến đổi Cây cú pháp trừu tượng (Abstract Syntax Tree - AST). Các thư viện như @babel/parser để phân tích mã nguồn thành AST, @babel/traverse để duyệt qua AST, và @babel/generator để tạo lại mã nguồn từ AST đã biến đổi sẽ rất hữu ích.

Dưới đây là một ví dụ script Node.js (.mjs) thực hiện các biến đổi trên:

import { createRequire } from 'module';
import * as parser from '@babel/parser';
import * as t from '@babel/types';
import fs from 'fs';
import path from 'path';

const require = createRequire(import.meta.url);
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;

// Hàm kiểm tra chuỗi có chứa ký tự tiếng Việt không
function kiemTraTiengViet(chuoi) {
  return /[\u00C0-\u1EF9\u00A0-\u1EF9\uFEFF]/.test(chuoi); // Bao gồm các ký tự có dấu và byte order mark
}

// Tạo JSXElement cho Trans
function taoNutTrans(vanBan) {
  return t.jsxElement(
    t.jsxOpeningElement(t.jsxIdentifier('Trans'), [], false),
    t.jsxClosingElement(t.jsxIdentifier('Trans')),
    [t.jsxText(vanBan)],
    false
  );
}

// Tạo tagged template expression cho t`...`
function taoBieuThuc_t(vanBan) {
  return t.taggedTemplateExpression(
    t.identifier('t'),
    t.templateLiteral(
      [t.templateElement({ raw: vanBan, cooked: vanBan }, true)],
      []
    )
  );
}

// Tạo JSXExpressionContainer
function taoContainerBieuThucJSX(bieuThuc) {
  return t.jsxExpressionContainer(bieuThuc);
}

// Kiểm tra xem một path có phải là khai báo React component không
function laKhaiBaoReactComponent(path) {
  // Bỏ qua các khai báo enum
  if (path.findParent((p) => p.node.type === 'TSEnumDeclaration')) {
    return false;
  }

  // Function component
  if (
    path.type === 'FunctionDeclaration' &&
    path.node.id &&
    /^[A-Z]/.test(path.node.id.name)
  ) {
    return true;
  }

  // Arrow function component (vd: const MyComp = () => {...})
  if (
    path.type === 'VariableDeclarator' &&
    path.node.init?.type === 'ArrowFunctionExpression' &&
    path.node.id &&
    /^[A-Z]/.test(path.node.id.name)
  ) {
    return true;
  }

  // Class component
  if (
    path.type === 'ClassDeclaration' &&
    path.node.superClass &&
    ((path.node.superClass.type === 'MemberExpression' &&
      path.node.superClass.object.name === 'React' &&
      path.node.superClass.property.name === 'Component') ||
      (path.node.superClass.type === 'Identifier' &&
        path.node.superClass.name === 'Component'))
  ) {
    return true;
  }

  // Component được bọc trong forwardRef
  if (
    (path.type === 'FunctionExpression' || path.type === 'ArrowFunctionExpression') &&
    path.parent?.type === 'CallExpression' &&
    path.parent.callee?.name === 'forwardRef' &&
    (t.isVariableDeclarator(path.parent.parent) || t.isExportDefaultDeclaration(path.parent.parent))
  ) {
    return true;
  }

  return false;
}

// Kiểm tra xem một path có nằm trong phạm vi của một React component không
function benTrongReactComponent(path) {
  let currentPath = path;
  while (currentPath) {
    if (laKhaiBaoReactComponent(currentPath)) {
      return true;
    }
    // Xử lý trường hợp function expression / arrow function expression của component con
    // hoặc hàm tiện ích bên trong component.
    // Nếu nó là JSX, nó phải nằm trong component.
    if (t.isJSXElement(currentPath.node) || t.isJSXFragment(currentPath.node)) {
        return true;
    }
    currentPath = currentPath.parentPath;
  }
  return false;
}

// Tạo khai báo import
function taoKhaiBaoImport(cacBoChiDinh, nguon) {
  return t.importDeclaration(
    cacBoChiDinh.map((name) =>
      t.importSpecifier(t.identifier(name), t.identifier(name))
    ),
    t.stringLiteral(nguon)
  );
}

// Tạo khai báo useLingui hook
function taoKhaiBaoUseLingui() {
  return t.variableDeclaration('const', [
    t.variableDeclarator(
      t.objectPattern([
        t.objectProperty(t.identifier('t'), t.identifier('t'), false, true)
      ]),
      t.callExpression(t.identifier('useLingui'), [])
    )
  ]);
}

// Xử lý một tệp duy nhất
function xuLyTapTin(duongDanTapTin) {
  try {
    if (!/\.(tsx?|jsx?)$/.test(duongDanTapTin)) {
      return;
    }

    console.log('Đang xử lý tệp:', duongDanTapTin);

    const maNguon = fs.readFileSync(duongDanTapTin, 'utf-8');
    const tenTapTin = path.basename(duongDanTapTin).split('.')[0];

    const ast = parser.parse(maNguon, {
      sourceType: 'module',
      plugins: ['jsx', 'typescript', 'decorators-legacy'],
      tokens: true,
      attachComment: true
    });

    let canChenLinguiMacroImport = false;
    let canChenLinguiReactHook = new Set(); // Lưu tên component cần hook
    let daCoImportLingui = false;

    // Loại bỏ các import lingui.js hiện có để tránh trùng lặp
    traverse(ast, {
      ImportDeclaration(path) {
        const source = path.node.source.value;
        if (
          source === '@lingui/macro' ||
          source === '@lingui/react' ||
          source === '@lingui/react/macro' ||
          source === '@lingui/core/macro' ||
          source === '@lingui/core'
        ) {
          daCoImportLingui = true;
          path.remove();
        }
      }
    });

    // Vòng lặp chính để biến đổi AST
    traverse(ast, {
      // Xác định các component cần useLingui hook
      'FunctionDeclaration|VariableDeclarator|ClassDeclaration|CallExpression'(path) {
        if (!laKhaiBaoReactComponent(path)) return;

        let tenComponent;
        if (t.isFunctionDeclaration(path.node)) {
          tenComponent = path.node.id.name;
        } else if (t.isVariableDeclarator(path.node)) {
          tenComponent = path.node.id.name;
        } else if (t.isClassDeclaration(path.node)) {
          tenComponent = path.node.id.name;
        } else if (t.isCallExpression(path.node) && path.node.callee.name === 'forwardRef') {
          if (t.isVariableDeclarator(path.parent)) {
            tenComponent = path.parent.id.name;
          } else if (t.isExportDefaultDeclaration(path.parent)) {
            tenComponent = tenTapTin; // Dùng tên file nếu là export default
          }
        }

        if (tenComponent) {
          canChenLinguiReactHook.add(tenComponent);
        }
      },

      // Xử lý thuộc tính JSX chứa chuỗi tiếng Việt
      JSXAttribute: {
        exit(path) {
          if (!benTrongReactComponent(path)) return;

          const value = path.node.value;
          if (t.isStringLiteral(value) && kiemTraTiengViet(value.value)) {
            path.node.value = taoContainerBieuThucJSX(
              taoBieuThuc_t(value.value)
            );
            canChenLinguiMacroImport = true;
          }
        }
      },

      // Xử lý văn bản JSX chứa chuỗi tiếng Việt
      JSXText: {
        exit(path) {
          if (!benTrongReactComponent(path)) return;

          const vanBan = path.node.value.trim();
          if (kiemTraTiengViet(vanBan) && vanBan.length > 0) {
            const parent = path.parent;
            // Tránh bọc lại nếu đã là <Trans>
            if (t.isJSXElement(parent) && t.isJSXIdentifier(parent.openingElement.name) && parent.openingElement.name.name === 'Trans') {
              canChenLinguiMacroImport = true;
              return;
            }
            path.replaceWith(taoNutTrans(path.node.value));
            canChenLinguiMacroImport = true;
          }
        }
      },

      // Xử lý chuỗi StringLiteral trong mã JS (ngoài JSX attributes)
      StringLiteral: {
        exit(path) {
          // Bỏ qua nếu là khai báo enum hoặc import
          if (
            path.findParent((p) => p.node.type === 'TSEnumDeclaration') ||
            path.findParent((p) => p.isImportDeclaration())
          ) {
            return;
          }

          if (
            !path.node.value ||
            !kiemTraTiengViet(path.node.value) ||
            path.findParent(
              (p) =>
                p.isJSXElement() && t.isJSXIdentifier(p.node.openingElement.name) && p.node.openingElement.name.name === 'Trans'
            )
          ) {
            return;
          }

          // Nếu chuỗi nằm trong thuộc tính JSX, đã được xử lý ở JSXAttribute
          if (path.parent.type === 'JSXAttribute') {
            return;
          }

          // Nếu chuỗi nằm trong một component React
          if (benTrongReactComponent(path)) {
            path.replaceWith(taoBieuThuc_t(path.node.value));
            canChenLinguiMacroImport = true;
          } else {
            // Chuỗi nằm ngoài component React, có thể dùng t từ @lingui/core/macro
            // Hoặc có thể bỏ qua nếu chỉ tập trung vào nội dung component
            // Hiện tại, script sẽ bỏ qua để tránh chuyển đổi quá mức
          }
        }
      }
    });

    // Vòng lặp để chèn useLingui hook vào các component đã xác định
    traverse(ast, {
      'FunctionDeclaration|ArrowFunctionExpression'(path) {
        let tenComponent;
        let bodyNode;

        if (t.isFunctionDeclaration(path.node)) {
          tenComponent = path.node.id?.name;
          bodyNode = path.node.body;
        } else if (t.isVariableDeclarator(path.parent) && t.isArrowFunctionExpression(path.node)) {
          tenComponent = path.parent.id?.name;
          bodyNode = path.node.body;
        } else if (path.parent?.type === 'CallExpression' && path.parent.callee?.name === 'forwardRef') {
          // Xử lý forwardRef
          if (t.isVariableDeclarator(path.parent.parent)) {
            tenComponent = path.parent.parent.id.name;
            bodyNode = path.node.body;
          } else if (t.isExportDefaultDeclaration(path.parent.parent)) {
            tenComponent = tenTapTin; // Dùng tên file
            bodyNode = path.node.body;
          }
        }

        if (tenComponent && canChenLinguiReactHook.has(tenComponent)) {
          if (t.isBlockStatement(bodyNode)) {
            // Kiểm tra xem đã có useLingui hook chưa
            const coUseLingui = bodyNode.body.some(
              (node) =>
                t.isVariableDeclaration(node) &&
                node.declarations.some(
                  (dec) =>
                    t.isCallExpression(dec.init) &&
                    t.isIdentifier(dec.init.callee) &&
                    dec.init.callee.name === 'useLingui'
                )
            );
            if (!coUseLingui) {
              bodyNode.body.unshift(taoKhaiBaoUseLingui());
            }
          } else if (t.isJSXElement(bodyNode) || t.isJSXFragment(bodyNode)) {
            // Nếu component trả về JSX trực tiếp, bọc trong block statement
            path.node.body = t.blockStatement([
              taoKhaiBaoUseLingui(),
              t.returnStatement(bodyNode)
            ]);
          }
        }
      }
    });

    // Thêm các câu lệnh import cần thiết vào đầu tệp
    const cacImportMoi = [];
    if (canChenLinguiMacroImport) {
      cacImportMoi.push(
        taoKhaiBaoImport(['useLingui', 'Trans'], '@lingui/react/macro')
      );
    }
    
    // Chèn các import vào đầu chương trình, sau các import khác
    if (cacImportMoi.length > 0) {
        let lastImportIndex = -1;
        for (let i = 0; i < ast.program.body.length; i++) {
            if (t.isImportDeclaration(ast.program.body[i])) {
                lastImportIndex = i;
            }
        }
        ast.program.body.splice(lastImportIndex + 1, 0, ...cacImportMoi);
    }


    // Tạo mã nguồn đã biến đổi
    const output = generate(ast, {
      retainLines: true,
      compact: 'auto',
      concise: false,
      jsescOption: {
        minimal: true
      },
      sourceMaps: false,
      comments: true
    });

    // Ghi đè lên tệp gốc
    fs.writeFileSync(duongDanTapTin, output.code);
    console.log('Đã cập nhật tệp:', duongDanTapTin);
  } catch (error) {
    console.error(`Lỗi khi xử lý tệp ${duongDanTapTin}:`, error);
  }
}

// Xử lý thư mục
function xuLyThuMuc(duongDanThuMuc) {
  try {
    const cacTapTin = fs.readdirSync(duongDanThuMuc);

    cacTapTin.forEach((tapTin) => {
      const duongDanDayDu = path.join(duongDanThuMuc, tapTin);
      const thongTinStat = fs.statSync(duongDanDayDu);

      if (thongTinStat.isDirectory()) {
        xuLyThuMuc(duongDanDayDu); // Đệ quy xử lý thư mục con
      } else {
        xuLyTapTin(duongDanDayDu); // Xử lý tệp
      }
    });
  } catch (error) {
    console.error(`Lỗi khi xử lý thư mục ${duongDanThuMuc}:`, error);
  }
}

// Lấy tham số từ dòng lệnh
const duongDanMucTieu = process.argv[2];

if (!duongDanMucTieu) {
  console.error('Vui lòng cung cấp đường dẫn tệp hoặc thư mục!');
  console.log('Cách sử dụng: node auto-dich-lingui-ast.mjs <đường_dẫn_tệp_hoặc_thư_mục>');
  process.exit(1);
}

// Kiểm tra xem đường dẫn có tồn tại không
if (!fs.existsSync(duongDanMucTieu)) {
  console.error('Đường dẫn được chỉ định không tồn tại!');
  process.exit(1);
}

// Xác định là tệp hay thư mục để xử lý
const thongTinStatMucTieu = fs.statSync(duongDanMucTieu);
if (thongTinStatMucTieu.isDirectory()) {
  xuLyThuMuc(duongDanMucTieu);
} else {
  xuLyTapTin(duongDanMucTieu);
}

Cách sử dụng script

Lưu script trên vào một tệp có tên auto-dich-lingui-ast.mjs. Sau đó, chạy nó từ terminal, cung cấp đường dẫn đến tệp hoặc thư mục cần xử lý:

node auto-dich-lingui-ast.mjs src/components/MyComponent.tsx
# Hoặc để xử lý toàn bộ thư mục src
node auto-dich-lingui-ast.mjs src

Script sẽ quét các tệp .tsx/.jsx/.ts/.js, phát hiện các chuỗi tiếng Việt, tự động bọc chúng bằng t`...` hoặc <Trans>...</Trans>, chèn useLingui hook và các câu lệnh import cần thiết. Sau khi script chạy xong, bạn có thể tiếp tục với quy trình lingui extractlingui compile để tạo và quản lý các tệp dịch.

Các gói phụ thuộc

Để script trên và lingui.js hoạt động, bạn cần cài đặt các gói sau:

{
    "scripts": {
      "i18n:extract": "lingui extract",
      "i18n:compile": "lingui compile"
    },
    "dependencies": {
      "@lingui/core": "^5.x.x",
      "@lingui/macro": "^5.x.x",
      "@lingui/react": "^5.x.x"
    },
    "devDependencies": {
      "@lingui/babel-plugin-lingui-macro": "^5.x.x",
      "@lingui/cli": "^5.x.x",
      "@lingui/vite-plugin": "^5.x.x",
      "@babel/parser": "^7.x.x",
      "@babel/traverse": "^7.x.x",
      "@babel/generator": "^7.x.x",
      "@babel/types": "^7.x.x"
    }
}

Thẻ: react i18n lingui.js AST babel

Đăng vào ngày 29 tháng 7 lúc 00:10