Repository: xiCO2k/laravel-vue-i18n
Pull request #177 (2024-05-22 04:42:25)

Support for published package language vendor files
This update addresses an issue where a 'php_vendor.json' file is generated but not utilized, as it is not imported by the plugin resolver.

**Changes**

- Removed the generation of the 'php_vendor.json' file from the published package language files.
- Merged the content of 'php_vendor.json' directly into the actual language files used by the resolver.

xiCO2k: Hey @Haruki1707 thanks for the PR, can you add a test for this one?

Thanks.
Haruki1707: Sure, I’ve added a test for it. Feel free to review and make any modifications as needed, since I’m not very experienced in testing.
Haruki1707: I found some improvements, such as moving the merge logic from `prepareExtendedParsedLanguage` to `parseAll`, where I have direct access to the folder name (or lang) used to determine the vendor translation keys that need to be merged into a specific lang. 

Additionally, moving part of the complex merging logic to a separate function makes the `parseAll` code easier to understand.
xiCO2k: Thanks for that will review it during the weekend
Haruki1707: Sorry, this commit was meant to be part of another PR. It addresses a problem with the tests that can occur randomly. Tests within a "test suite" are run synchronously, but different test suites are run asynchronously. This can sometimes cause `translate.test` and `loader.test` to interfere with each other, as happened in the latest test workflow.

This commit isolates the folder that `loader.test` uses to prevent it from interfering with `translate.test`. It achieves this by generating a copy of the `fixtures` folder and using it exclusively for all the tests inside `loader.test`.
xiCO2k: Thanks for that. One question @Haruki1707 if we have the same thing just in php the key will be the same?

Thanks.
Haruki1707: @xiCO2k yes, it will be the same key as in PHP. Here is an example from a package I'm using and how the key is structured:

```php
if (Auth::user()->confirmTwoFactorAuth($request->code)) {
    return back()->with('success', trans('two-factor::messages.enabled'));
}
```

As you can see, the structure of the key for packages is `package-name::file.translation_string`.

Currently, without this PR, what gets generated in `php_vendor.json` is `package_name.lang.file.translation_string`. If you check line 83 in the commits of `loader.ts`, you'll see that the `.folder.` part (which represents the language) is replaced by `::`. This makes it match the PHP key format `package-name::file.translation_string` and then injects it into the corresponding php_lang.json.

```ts
[key.replace(`.${folder}.`, '::')]: value
```

Patch:
diff --git a/src/loader.ts b/src/loader.ts
index 22332ac..b0bb14a 100644
--- a/src/loader.ts
+++ b/src/loader.ts
@@ -50,6 +50,17 @@ export const parseAll = (folderPath: string): ParsedLangFileInterface[] => {
     })
   }
 
+  // If data contains an object with folder name 'vendor'
+  const vendorIndex = data.findIndex(({ folder }) => folder === 'vendor');
+
+  if (vendorIndex !== -1) {
+    const vendorTranslations = data[vendorIndex].translations;
+    data.splice(vendorIndex, 1);
+
+    data.forEach(langFile =>
+      langFile.translations = mergeVendorTranslations(langFile.folder, langFile.translations, vendorTranslations));
+  }
+
   return data
     .filter(({ translations }) => {
       return Object.keys(translations).length > 0
@@ -62,6 +73,20 @@ export const parseAll = (folderPath: string): ParsedLangFileInterface[] => {
     })
 }
 
+function mergeVendorTranslations(folder: string, translations: any, vendorTranslations: any) {
+  // Filter the translations from the vendor file that match the current folder
+  const langTranslationsFromVendor = Object
+    .entries(vendorTranslations)
+    .filter(([key]) => key.includes(`.${folder}.`))
+    .reduce((acc, [key, value]) => ({
+      ...acc,
+      [key.replace(`.${folder}.`, '::')]: value,
+    }), {});
+
+  // Merge the vendor translations that matched the folder with the current translations
+  return { ...translations, ...langTranslationsFromVendor };
+}
+
 export const parse = (content: string) => {
   const arr = new Engine({}).parseCode(content, 'lang').children.filter((child) => child.kind === 'return')[0] as any
 
@@ -155,8 +180,8 @@ export const readThroughDir = (dir) => {
   return data
 }
 
-export const prepareExtendedParsedLangFiles = (langPaths: string[]) =>
-  langPaths.reduce((acc, langPath) => [...acc, ...parseAll(langPath)], new Array<ParsedLangFileInterface>())
+export const prepareExtendedParsedLangFiles = (langPaths: string[]): ParsedLangFileInterface[] =>
+  langPaths.flatMap(langPath => parseAll(langPath));
 
 export const generateFiles = (langPath: string, data: ParsedLangFileInterface[]): ParsedLangFileInterface[] => {
   data = mergeData(data)


Test patch:
diff --git a/test/fixtures/lang/vendor/package-example/en/messages.php b/test/fixtures/lang/vendor/package-example/en/messages.php
new file mode 100644
index 0000000..4cfb5b2
--- /dev/null
+++ b/test/fixtures/lang/vendor/package-example/en/messages.php
@@ -0,0 +1,17 @@
+<?php
+
+return [
+    'welcome' => 'Welcome to the example package.',
+    'success' => 'The package did the task successfully.',
+    'foo' => [
+        'level1' => [
+            'level2' => 'package'
+        ]
+    ],
+    'arr' => ['foo', 'bar'],
+    'multiline' => 'Lorem ' .
+        'ipsum ' .
+        'dolor ' .
+        'sit ' .
+        'amet.',
+];
diff --git a/test/fixtures/lang/vendor/package-example/pt/messages.php b/test/fixtures/lang/vendor/package-example/pt/messages.php
new file mode 100644
index 0000000..edec017
--- /dev/null
+++ b/test/fixtures/lang/vendor/package-example/pt/messages.php
@@ -0,0 +1,17 @@
+<?php
+
+return [
+    'welcome' => 'Bem-vindo ao exemplo do pacote.',
+    'success' => 'O pacote executou a tarefa com sucesso.',
+    'foo' => [
+        'level1' => [
+            'level2' => 'pacote'
+        ]
+    ],
+    'arr' => ['foo', 'bar'],
+    'multiline' => 'Lorem ' .
+        'ipsum ' .
+        'dolor ' .
+        'sit ' .
+        'amet.',
+];
diff --git a/test/folderIsolationUtil.ts b/test/folderIsolationUtil.ts
new file mode 100644
index 0000000..57f2679
--- /dev/null
+++ b/test/folderIsolationUtil.ts
@@ -0,0 +1,27 @@
+import fs from 'fs'
+import path from 'path'
+
+export function isolateFolder(folderToIsolate: string, testName: string) {
+    const isolatedFolder = folderToIsolate + '_isolated_' + testName;
+    copyDirSync(folderToIsolate, isolatedFolder);
+
+    return isolatedFolder;
+}
+
+export function removeIsolatedFolder(isolatedFolder: string) {
+    fs.rmSync(isolatedFolder, { recursive: true, force: true })
+}
+
+function copyDirSync(source: string, destination: string) {
+    const exists = fs.existsSync(source);
+    const stats = exists && fs.statSync(source);
+    const isDirectory = exists && stats.isDirectory();
+    if (isDirectory) {
+        fs.mkdirSync(destination);
+        fs.readdirSync(source).forEach(childItemName => {
+            copyDirSync(path.join(source, childItemName), path.join(destination, childItemName));
+        });
+    } else {
+        fs.copyFileSync(source, destination);
+    }
+}
\ No newline at end of file
diff --git a/test/loader.test.ts b/test/loader.test.ts
index 55e722b..f8fddbf 100644
--- a/test/loader.test.ts
+++ b/test/loader.test.ts
@@ -1,10 +1,14 @@
 import fs from 'fs';
 import { generateFiles, parseAll, parse, hasPhpTranslations, reset, prepareExtendedParsedLangFiles } from '../src/loader';
+import { isolateFolder, removeIsolatedFolder } from './folderIsolationUtil'
 
-beforeEach(() => reset(__dirname + '/fixtures/lang/'));
+const isolatedFixtures = isolateFolder(__dirname + '/fixtures', 'loader');
+afterAll(() => removeIsolatedFolder(isolatedFixtures));
+
+beforeEach(() => reset(isolatedFixtures + '/lang/'));
 
 it('creates a file for each lang', () => {
-    const langPath = __dirname + '/fixtures/lang/';
+    const langPath = isolatedFixtures + '/lang/';
     const files = generateFiles(langPath, parseAll(langPath));
 
     expect(files.length).toBe(3);
@@ -22,8 +26,32 @@ it('creates a file for each lang', () => {
     expect(langPt['auth.foo.level1.level2']).toBe('barpt');
 });
 
+it('merges published package translations into each lang .json', () => {
+    const langPath = isolatedFixtures + '/lang/';
+    const files = generateFiles(langPath, parseAll(langPath));
+
+    expect(files.length).toBe(3);
+    expect(files[0].name).toBe('php_en.json');
+    expect(files[1].name).toBe('php_fr.json');
+    expect(files[2].name).toBe('php_pt.json');
+
+    const langEn = JSON.parse(fs.readFileSync(langPath + files[0].name).toString());
+    expect(langEn['package-example::messages.welcome']).toBe('Welcome to the example package.');
+    expect(langEn['package-example::messages.foo.level1.level2']).toBe('package');
+    expect(langEn['package-example::messages.multiline']).toBe('Lorem ipsum dolor sit amet.');
+
+    const langFr = JSON.parse(fs.readFileSync(langPath + files[1].name).toString());
+    expect(langFr['package-example::messages.welcome']).toBeUndefined();
+    expect(langFr['package-example::messages.foo.level1.level2']).toBeUndefined();
+    expect(langFr['package-example::messages.multiline']).toBeUndefined();
+
+    const langPt = JSON.parse(fs.readFileSync(langPath + files[2].name).toString());
+    expect(langPt['package-example::messages.welcome']).toBe('Bem-vindo ao exemplo do pacote.');
+    expect(langPt['package-example::messages.foo.level1.level2']).toBe('pacote');
+});
+
 it('includes .php lang file in subdirectory in .json', () => {
-    const langPath = __dirname + '/fixtures/lang/';
+    const langPath = isolatedFixtures + '/lang/';
     const files = generateFiles(langPath, parseAll(langPath));
     const langEn = JSON.parse(fs.readFileSync(langPath + files[0].name).toString());
 
@@ -33,7 +61,7 @@ it('includes .php lang file in subdirectory in .json', () => {
 });
 
 it('includes .php lang file in nested subdirectory in .json', () => {
-    const langPath = __dirname + '/fixtures/lang/';
+    const langPath = isolatedFixtures + '/lang/';
     const files = generateFiles(langPath, parseAll(langPath));
     const langEn = JSON.parse(fs.readFileSync(langPath + files[0].name).toString())
 
@@ -42,9 +70,9 @@ it('includes .php lang file in nested subdirectory in .json', () => {
 })
 
 it('inclues additional lang paths to load from', () => {
-    const langPath = __dirname + '/fixtures/lang/';
+    const langPath = isolatedFixtures + '/lang/';
     const additionalLangPaths = [
-        __dirname + '/fixtures/locales/'
+        isolatedFixtures + '/locales/'
     ];
 
     const langPaths = prepareExtendedParsedLangFiles([
@@ -60,9 +88,9 @@ it('inclues additional lang paths to load from', () => {
 });
 
 it('overwrites translations from additional lang paths', () => {
-    const langPath = __dirname + '/fixtures/lang/';
+    const langPath = isolatedFixtures + '/lang/';
     const additionalLangPaths = [
-        __dirname + '/fixtures/locales/'
+        isolatedFixtures + '/locales/'
     ];
 
     const langPaths = prepareExtendedParsedLangFiles([
@@ -79,33 +107,33 @@ it('overwrites translations from additional lang paths', () => {
 });
 
 it('transforms .php lang to .json', () => {
-    const lang = parse(fs.readFileSync(__dirname + '/fixtures/lang/en/auth.php').toString());
+    const lang = parse(fs.readFileSync(isolatedFixtures + '/lang/en/auth.php').toString());
 
     expect(lang['failed']).toBe('These credentials do not match our records.');
 });
 
 it('transform nested .php lang files to .json', () => {
-    const langPt = parse(fs.readFileSync(__dirname + '/fixtures/lang/pt/auth.php').toString());
+    const langPt = parse(fs.readFileSync(isolatedFixtures + '/lang/pt/auth.php').toString());
     expect(langPt['foo.level1.level2']).toBe('barpt');
 
-    const langEn = parse(fs.readFileSync(__dirname + '/fixtures/lang/en/auth.php').toString());
+    const langEn = parse(fs.readFileSync(isolatedFixtures + '/lang/en/auth.php').toString());
     expect(langEn['foo.level1.level2']).toBe('baren');
 });
 
 it('transforms simple index array to .json', () => {
-    const lang = parse(fs.readFileSync(__dirname + '/fixtures/lang/en/auth.php').toString());
+    const lang = parse(fs.readFileSync(isolatedFixtures + '/lang/en/auth.php').toString());
     expect(lang['arr.0']).toBe('foo');
     expect(lang['arr.1']).toBe('bar');
 });
 
 it('ignores empty `array` or `null` translations', () => {
-    const lang = parse(fs.readFileSync(__dirname + '/fixtures/lang/en/ignore.php').toString());
+    const lang = parse(fs.readFileSync(isolatedFixtures + '/lang/en/ignore.php').toString());
 
     expect(lang['empty_array']).toBe(undefined);
     expect(lang['null']).toBe(undefined);
 });
 
 it('checks if there is .php translations', () => {
-    expect(hasPhpTranslations(__dirname + '/fixtures/lang/')).toBe(true);
-    expect(hasPhpTranslations(__dirname + '/fixtures/wronglangfolder/')).toBe(false);
+    expect(hasPhpTranslations(isolatedFixtures + '/lang/')).toBe(true);
+    expect(hasPhpTranslations(isolatedFixtures + '/wronglangfolder/')).toBe(false);
 });