Skip to content

AI generated i18n key example in a React project (Primary language: English) #28

@lvboda

Description

@lvboda

You need to replace AIGC_API or import OpenAI SDK.

const AIGC_API = ''; // your aigc api

/**
 * i18n-fast hook
 * - Node.js runtime
 * - Use CommonJS
 * - Can require other modules
 * - Can return a Promise
*/

/**
 * @typedef {import('vscode')} Vscode - see {@link https://code.visualstudio.com/api/references/vscode-api}
*/

/**
 * @typedef {'document'} MatchType - see {@link https://github.com/lvboda/vscode-i18n-fast/blob/main/src/utils/constant.ts}
*/

/**
 * @typedef {0 | 1 | 2 | 4 | 7} SupportType - see {@link https://github.com/lvboda/vscode-i18n-fast/blob/main/src/utils/constant.ts}
*/

/**
 * @typedef {Object} ConvertGroup - see {@link https://github.com/lvboda/vscode-i18n-fast/blob/main/src/types/index.ts}
 * @property {string} i18nValue - i18n value
 * @property {string} [matchedText] - original matched text
 * @property {Vscode.Range} [range] - matched range see {@link https://code.visualstudio.com/api/references/vscode-api#Range}
 * @property {string} [i18nKey] - i18n key
 * @property {Record<string, any>} [params] - custom params
 * @property {string} [overwriteText] - overwrite text
 * @property {'exist' | 'new'} [type] - i18n type
 */

/**
 * @typedef {Object} I18nGroup - see {@link https://github.com/lvboda/vscode-i18n-fast/blob/main/src/types/index.ts}
 * @property {string} key - i18n key
 * @property {string} value - i18n value
 * @property {import('@formatjs/icu-messageformat-parser').MessageFormatElement[]} [valueAST] - value AST see {@link https://www.npmjs.com/package/@formatjs/icu-messageformat-parser}
 * @property {string} [filePath] - The file path where the key-value pair is defined.
 * @property {number} [line] - The line number in the file where the key-value pair is defined.
 * @property {Vscode.Range} [range] - The range that appears in the document see {@link https://code.visualstudio.com/api/references/vscode-api#Range}
 * @property {SupportType} [supportType] - Support behavior type
 * @property {Vscode.DecorationOptions['renderOptions']} [renderOption] - see {@link https://code.visualstudio.com/api/references/vscode-api#DecorationRenderOptions}
 * @property {Vscode.DecorationOptions['hoverMessage']} [hoverMessage] - see {@link https://code.visualstudio.com/api/references/vscode-api#DecorationRenderOptions}
 * @property {Vscode.Definition | Vscode.DefinitionLink[]} [locationLink] - see {@link https://code.visualstudio.com/api/references/vscode-api#Definition}
 */

/**
 * @typedef {Object} I18n - see {@link https://github.com/lvboda/vscode-i18n-fast/blob/main/src/utils/i18n.ts}
 * @property {(workspaceKey?: string) => Map<string, I18nGroup[]> | Map<string, Map<string, I18nGroup[]>>} get
 * @property {(workspaceKey?: string) => I18nGroup[]} getI18nGroups
*/

/**
 * some tools
 * @typedef {Object} Context
 * @property {Vscode} vscode
 * @property {Vscode.ExtensionContext} extensionContext
 * @property {import('qs')} qs - see {@link https://www.npmjs.com/package/qs}
 * @property {import('crypto-js')} crypto - see {@link https://www.npmjs.com/package/crypto-js}
 * @property {import('uuid')} uuid - see {@link https://www.npmjs.com/package/uuid}
 * @property {import('lodash')} _ - see {@link https://www.npmjs.com/package/lodash}
 * @property {import('@babel/parser') & { traverse: import('@babel/traverse') }} babel - see {@link https://www.npmjs.com/package/@babel/parser}, {@link https://www.npmjs.com/package/@babel/traverse}
 * @property {typeof module.exports} hook
 * @property {I18n} i18n
 * @property {(str: string, opt: { separator?: string, lowerCase?: boolean, limit?: number, forceSplit?: boolean }) => string} convert2pinyin - see {@link https://github.com/lvboda/vscode-i18n-fast/blob/main/src/utils/index.ts}
 * @property {(input: string | import('@babel/types').Node, start: number, end: number) => boolean} isInJsxElement - see {@link https://github.com/lvboda/vscode-i18n-fast/blob/main/src/utils/index.ts}
 * @property {(input: string | import('@babel/types').Node, start: number, end: number) => boolean} isInJsxAttribute - see {@link https://github.com/lvboda/vscode-i18n-fast/blob/main/src/utils/index.ts}
 * @property {(fileUri: Vscode.Uri | string, contentOrList: string | ({ range: Range, content: string }[]), isSave = false, needSnapshot = true) => Promise<boolean>} writeFileByEditor - see {@link https://github.com/lvboda/vscode-i18n-fast/blob/main/src/utils/index.ts}
 * @property {(message: string) => I18nGroup['valueAST'][]} getICUMessageFormatAST - see {@link https://github.com/lvboda/vscode-i18n-fast/blob/main/src/utils/index.ts}
 * @property {(fn: T, args: Parameters<T>, errorCb?: (error: any) => ReturnType<T>) => ReturnType<T>} safeCall - see {@link https://github.com/lvboda/vscode-i18n-fast/blob/main/src/utils/index.ts}
 * @property {(fn: T, args: Parameters<T>, errorCb?: (error: any) => ReturnType<T>) => Promise<ReturnType<T>>} asyncSafeCall - see {@link https://github.com/lvboda/vscode-i18n-fast/blob/main/src/utils/index.ts}
 * @property {() => Record<string, string>} getConfig - see {@link https://github.com/lvboda/vscode-i18n-fast/blob/main/src/utils/config.ts}
 * @property {() => boolean} getLoading - see {@link https://github.com/lvboda/vscode-i18n-fast/blob/main/src/utils/index.ts}
 * @property {(loading: boolean, text?: string) => void} setLoading - see {@link https://github.com/lvboda/vscode-i18n-fast/blob/main/src/utils/index.ts}
 * @property {(type: "info" | "warn" | "error", message: string, maxLength = 300, ...args: string[]) => void} showMessage - see {@link https://github.com/lvboda/vscode-i18n-fast/blob/main/src/utils/tips.ts}
 * @property {(document: Vscode.TextDocument) => ConvertGroup[]} matchChinese - see {@link https://github.com/lvboda/vscode-i18n-fast/blob/main/src/utils/index.ts}
*/

const examples = [
    {
        path: 'apps/i18n/locales/en-US/auth.js',
        map: {
            'app.auth.forgot.email.sent': 'We have sent you an email with a link to reset your password. If you do not receive an email, please check your spam folder or resend.',
            'app.auth.button.forgot.send': 'Send reset email',
            'app.auth.button.forgot.resend': 'Resend email',
            'app.auth.button.forgot.back': 'Go back to sign in',
            'app.auth.button.forgot.set': 'Set new password',
            'app.auth.reset.title': 'Reset your password',
            'app.auth.reset.description': 'Enter your new password below.',
            'app.auth.fields.password.new': 'New password',
            'app.auth.fields.password.confirm': 'Confirm password',
            'app.auth.button.reset': 'Reset password',
        },
    },
    {
        path: 'apps/i18n/locales/en-US/user.js',
        map: {
            'app.user.search-features': 'Search Features',
            'app.user.analyzable-skus': 'Analyzable SKUs',
            'app.user.select-sku': 'Select SKU',
            'app.user.edit-charts-permission': 'Edit charts permission',
            'app.user.selected-charts': '{selected}/{all} charts',
            'app.user.add-new-role': '+ New Role',
            'app.user-count-fmt': '{count} items',
            'app.user-search-text-role': 'Search role',
            'app.user.role-description-placeholder': 'Max. 500 characters',
            'app.user.features-permission': 'Features permission',
        },
    },
    {
        path: 'apps/i18n/locales/en-US/channels.js',
        map: {
            'app.channels.account.auto.connect.switch.recommend': 'For ease of use, we recommend you enable the automatically connect.',
            'app.channels.account.email.addressee': 'The recipient configuration is complete.',
            'app.channels.account.email.send': 'The sender configuration is complete.',
            'app.channels.account.email.send-receive': 'The sending and receiving configurations are complete.',
            'app.channels.account.email.timeout': 'This email address has not sent or received any emails for {emailSetTimeDiff} days.',
            'app.channels.account.permission.item.profile': 'Account Profile',
            'app.channels.account.permission.item.no.tip': 'Permission missing, the system cannot operate properly. Please reauthorize.',
            'app.channels.account.amazon.select.site': 'Please select the account opening location',
            'app.channels.account.reason.auth_success_bind_failed': 'Account import failed, return to account list to retry',
            'app.channels.account.ebay.connect-apps.extra.tip': 'Connect this account to all your active apps, If you wish to connect only specific apps, please turn off "Automatically connect your active apps" outside.',
        },
    },
    {
        path: 'apps/i18n/locales/en-US/common.js',
        map: {
            'app.common.status': 'Status',
            'app.common.actions': 'Actions',
            'app.common.edit': 'Edit',
            'app.common.name': 'Name',
            'app.common.close': 'Close',
            'app.common.enable': 'Enable',
            'app.common.disable': 'Disable',
            'app.common.expired': 'Expired',
            'app.common.change': 'Change',
            'app.common.confirm': 'Confirm',
        },
    },
];

/**
 * @param {{ text: string, path: string }[]} inputs
 * @param {string[]} i18nFiles
 * @returns {{ originalText: string, i18nKey: string, path?: string }[]}
*/
const genI18nKey = async (inputs, i18nFiles) => {
    const systemPrompt = `You are an expert in generating i18n keys. Follow these instructions strictly:

1. **Input Format**:
   An array of objects, each containing:
   - text: The original text requiring an i18n key.
   - path: The file path where the text appears.

2. **Output Format**:
   Return a JSON object with a single key: \`result\`, each containing:
   - originalText: The original input text.
   - i18nKey: The generated i18n key.
   - path: The picked i18n file path.
    -- The \`path\` must be exactly one from the provided \`available i18n files\`. Do not add or remove directory levels.
    -- If no exact match exists, return \`undefined\`.
    -- Do not assume the existence of paths based on similar names. Only use paths explicitly listed.

3. **i18n Key Rules**:
   - Structure: \`app.[main module (optional)].[sub module (optional)].[semantic content]\`.
   - Extract module names from file paths when relevant, but do not force matches.
   - Prefer nouns for semantic content.
   - Use lowercase and hyphens for multi-word keys.
   - Keep it concise.

4. **File Matching Rules**:
   - Match the most relevant functional module.
   - Prioritize files in the same directory hierarchy.
   - Only use provided file names—do not create new ones.
   - Ensure that the matching process favors more specific modules over general ones.

5. **Response Requirements**:
   - Maintain the same input order in the output.
   - Ensure each input has a corresponding output.
   - You must return a **valid JSON string**, without extra text, explanations, or code blocks.`;

    const userPrompt = `### Input:
${JSON.stringify(inputs, null, 2)}

### Reference Examples:
${examples.map(e => `[File] ${e.path}\n[Keys]\n${Object.entries(e.map).map(([k, v]) => `${k} = ${v}`).join('\n')}`).join('\n\n')}

### Available i18n Files:
${i18nFiles.join('\n')}
`;
    const res = await (await fetch(AIGC_API, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            system: systemPrompt,
            prompt: userPrompt,
            timeout: 30,
            max_retries: 3,
            model: "gpt-4o-mini",
            stream: false,
            messages: [{ role: "system", content: systemPrompt }, { role: "user", content: userPrompt }]
        })
    })).json();

    const { result } = JSON.parse(res.choices[0].message.content);

    if (inputs.some(({ text }, index) => !result[index].originalText === text)) throw new Error('generate structure error');

    return result;
}

module.exports = {
    /**
     * Custom matching logic
     * @param {Context & { document: Vscode.TextDocument }} context
     * @returns {ConvertGroup[] | Promise<ConvertGroup[]>}
     */
    match({ document }) {
        const documentText = document.getText();
        const matchedArr = documentText.match(/(?:(['"`])#\((.+?)\)\1|#\((.+?)\))/gs) || [];
        return matchedArr
            .map((matchedText) => {
                const i18nValue = [...matchedText.matchAll(/#\((.*?)\)/gs)]?.[0]?.[1];
                if (!i18nValue) return;
                return { matchedText, i18nValue };
            }).filter(Boolean);
    },

    /**
     * Convert data
     * @param {Context & { convertGroups: ConvertGroup[], document: Vscode.TextDocument }} context
     * @returns {ConvertGroup[] | Promise<ConvertGroup[]>}
     */
    convert({ convertGroups, document, safeCall, isInJsxElement, isInJsxAttribute, babel, qs, uuid }) {
        const documentText = document.getText();
        return convertGroups.map((group) => {
            const [i18nValue = group.i18nValue, paramsStr = ''] = group.i18nValue.split('?i');
            const params = { ...qs.parse(paramsStr) };

            const i18nKey = group.type === 'new' ? `i18n-fast-loading-${uuid.v4()}` : group.i18nKey;

            const startIndex = document.offsetAt(group.range.start);
            const endIndex = document.offsetAt(group.range.end);
            const AST = safeCall(() => babel.parse(documentText, {
              sourceType: 'module',
              plugins: ['typescript', 'jsx'],
              errorRecovery: true,
              allowImportExportEverywhere: true,
              allowReturnOutsideFunction: true,
              allowSuperOutsideMethod: true,
              allowUndeclaredExports: true,
              allowAwaitOutsideFunction: true,
            }));
            const inJsxOrJsxAttribute = safeCall(() => AST ? isInJsxElement(AST, startIndex, endIndex) : false) || safeCall(() => AST ? isInJsxAttribute(AST, startIndex, endIndex) : false);

            let overwriteText = `formatMessage({ id: '${i18nKey}' }${params.v ? `, { ${params.v} }` : ''})`;
            // need component code
            if (inJsxOrJsxAttribute || params.t === 'c') {
                overwriteText = `<FormattedMessage id="${i18nKey}" ${params.v ? `values={{ ${params.v} }}` : ''}/>`;
            }

            return {
                ...group,
                i18nKey,
                i18nValue,
                overwriteText,
                params,
            };
        });
    },

    /**
     * Write to ...
     * @param {Context & { convertGroups: ConvertGroup[], document: Vscode.TextDocument }} context
     * @returns {boolean | Promise<boolean>}
     */
    async write({ convertGroups, _, vscode, writeFileByEditor, document, setLoading, getConfig, showMessage }) {
        await writeFileByEditor(document.uri, convertGroups.filter(({ range, overwriteText }) => !_.isNil(range) && !_.isNil(overwriteText)).map(({ range, overwriteText }) => ({ range, content: overwriteText })));

        let needCreateGroups = convertGroups.filter(({ type }) => type === 'new');
        if (needCreateGroups.length === 0) return;

        setLoading(true);
        genI18nKey(
            _.uniqBy(needCreateGroups, 'i18nValue').map(({ i18nValue }) => ({ text: i18nValue, path: document.uri.fsPath })),
            (await vscode.workspace.findFiles(getConfig().i18nFilePattern)).map(({ fsPath }) => fsPath)
        ).then(async (generated) => {
            needCreateGroups = needCreateGroups
                .map((group) => {
                    const { i18nKey, path } = generated.find(({ originalText }) => originalText === group.i18nValue) || {};
                    if (i18nKey) {
                        group.overwriteI18nKeyRanges = [];
                        [...document.getText().matchAll(new RegExp(group.i18nKey, 'g'))].forEach((matched) => {
                            if (!_.isNil(matched.index)) {
                                const start = document.positionAt(matched.index);
                                const end = document.positionAt(matched.index + group.i18nKey.length);
                                group.overwriteI18nKeyRanges.push(new vscode.Range(start, end));
                            }
                        });
                        group.i18nKey = i18nKey;
                        group.i18nFilePath = path;
                    }
                    return group;
                })
                .filter(({ i18nKey, overwriteI18nKeyRanges }) => !_.isNil(i18nKey) && overwriteI18nKeyRanges.length > 0);

            if (!needCreateGroups.length) return;

            for (const [path, groups] of Object.entries(_.groupBy(needCreateGroups, 'i18nFilePath'))) {
                const i18nFilePath = (path && !path.includes('undefined')) ? vscode.Uri.file(path) : await vscode.workspace.findFiles('apps/i18n/locales/en-US/miss.js')?.[0];
                let i18nFileContent = (await vscode.workspace.fs.readFile(i18nFilePath)).toString();
                const regex = /module\.exports\s*=\s*(\{[\s\S]*\})/;

                if (!!i18nFileContent && !regex.test(i18nFileContent)) {
                    console.error(`${path} i18n file content is invalid`);
                    continue;
                }

                if (!i18nFileContent.trim()) {
                    i18nFileContent = 'module.exports = {\n};';
                }

                const content = _.unionBy(groups, item => `${item.i18nKey}_${item.i18nValue}`)
                    .reduce((pre, { i18nKey, i18nValue }) => {
                        if (i18nKey && i18nValue) {
                            pre += `\n  '${i18nKey}': '${i18nValue}',`;
                        }
                        return pre;
                    }, '');

                if (!content) continue;
                await writeFileByEditor(path, i18nFileContent.replace(/(\s*)([,\s]*)(\}\s*;?\s*)$/, `${/module\.exports\s*=\s*{\s*}\s*;/.test(i18nFileContent) ? '' : ','}${content}\n};`), true);
            }

            await writeFileByEditor(document.uri, needCreateGroups.map(({ i18nKey, overwriteI18nKeyRanges }) => overwriteI18nKeyRanges.map((range) => ({ range, content: i18nKey }))).flat());
        })
        .catch((error) => showMessage('error', `<genI18nKey error> ${error?.stack || error}`))
        .finally(() => setLoading(false));
    },

    /**
     * Collect i18n
     * @param {Context & { i18nFileUri: Vscode.Uri }} context
     * @returns {I18nGroup[] | Promise<I18nGroup[]>}
     */
    async collectI18n({ i18nFileUri, vscode, getICUMessageFormatAST, safeCall }) {
        const i18nFileContentLines = (await vscode.workspace.fs.readFile(i18nFileUri)).toString().split('\n');
        const matchedIndexSet = new Set();
        delete require.cache[require.resolve(i18nFileUri.fsPath)];

        const i18nMap = require(i18nFileUri.fsPath);

        return Object.entries(i18nMap)
            .sort(([aKey], [bKey]) => bKey.length - aKey.length)
            .map(([key, value]) => ({
                key,
                value,
                valueAST: safeCall(getICUMessageFormatAST, [value]),
                line: i18nFileContentLines.findIndex((line, index) => {
                    if (matchedIndexSet.has(index)) return false;
                    const hasKey = new RegExp(key).test(line);
                    if (hasKey) matchedIndexSet.add(index);
                    return hasKey;
                }) + 1,
            }));
    },

    /**
     * Match i18n
     * @param {Context & { type: MatchType, i18nGroups: I18nGroup[], document: Vscode.TextDocument }} context
     * @returns {I18nGroup[] | Promise<I18nGroup[]>}
     */
    matchI18n({ i18nGroups, document, vscode, _ }) {
        return i18nGroups.map((group) => {
            if (!_.includes(group.key, '.')) {
                group.supportType = 0;
                return group;
            }

            const start = document.getText(new vscode.Range(new vscode.Position(group.range.start.line, group.range.start.character - 1), group.range.start));
            const end = document.getText(new vscode.Range(group.range.end, new vscode.Position(group.range.end.line, group.range.end.character + 1)));

            if (![start, end].every((i) => ['"', "'", '`'].includes(i))) {
                group.supportType = 7 & ~1;
            }

            return group;
        });
    },
};

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions