Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/smart-bobcats-cheat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'gt': patch
---

Fix string behavior
34 changes: 34 additions & 0 deletions packages/cli/src/formats/json/__tests__/extractJson.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -819,6 +819,40 @@ describe('extractJson', () => {
);
});

it('should handle string sourceItem in object composite schema', () => {
const localContent = JSON.stringify({
content: {
en: 'Contact name, phone number, email, 2',
es: 'Nombre del contacto, número de teléfono, email, 2',
},
});

const result = extractJson(
localContent,
'test.json',
{
jsonSchema: {
'**/*.json': {
composite: {
'$.content': {
type: 'object',
include: ['$.*'],
},
},
},
},
},
'es',
'en'
);

expect(result).not.toBeNull();
const parsed = JSON.parse(result!);
expect(parsed['/content']).toBe(
'Nombre del contacto, número de teléfono, email, 2'
);
});

it('should handle empty translations object', () => {
const localContent = JSON.stringify({
translations: {},
Expand Down
42 changes: 42 additions & 0 deletions packages/cli/src/formats/json/__tests__/mergeJson.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,48 @@ describe('mergeJson', () => {
expect(parsed.translations.fr.desc).toBe('Description Française');
});

it('should merge string sourceItem in object composite schema', () => {
const originalContent = JSON.stringify({
content: {
en: 'Contact name, phone number, email, 2',
},
});

const targets = [
{
translatedContent: JSON.stringify({
'/content': 'Nombre del contacto, número de teléfono, email, 2',
}),
targetLocale: 'es',
},
];

const result = mergeJson(
originalContent,
'test.json',
{
jsonSchema: {
'**/*.json': {
composite: {
'$.content': {
type: 'object',
include: ['$.*'],
},
},
},
},
},
targets,
'en'
);

const parsed = JSON.parse(result[0]);
expect(parsed.content.en).toBe('Contact name, phone number, email, 2');
expect(parsed.content.es).toBe(
'Nombre del contacto, número de teléfono, email, 2'
);
});

it('should use existing target locale object when available', () => {
const originalContent = JSON.stringify({
translations: {
Expand Down
30 changes: 30 additions & 0 deletions packages/cli/src/formats/json/__tests__/parseJson.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1249,6 +1249,36 @@ describe('parseJson', () => {
expect(mockExit).toHaveBeenCalledWith(1);
});

it('should handle string sourceItem in object composite schema', () => {
const json = JSON.stringify({
content: {
en: 'Contact name, phone number, email, 2',
fr: 'Nom du contact, numéro de téléphone, email, 2',
},
});

const result = parseJson(
json,
path.join(__dirname, '../__mocks__', 'test.json'),
{
jsonSchema: {
'**/*.json': {
composite: {
'$.content': {
type: 'object',
include: ['$.*'],
},
},
},
},
},
'en'
);

const parsed = JSON.parse(result);
expect(parsed['/content']).toBe('Contact name, phone number, email, 2');
});

it('should preserve original formatting for non-matching files', () => {
const originalJson = `{
"formatted": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,9 +199,8 @@ describe('structuralTransform', () => {
);

const parsed = JSON.parse(result);
// Source text extracted with empty-string pointer (value is a string, not an object)
expect(parsed['/btn_save/i18n']).toEqual({ '': 'Save changes' });
expect(parsed['/btn_cancel/i18n']).toEqual({ '': 'Cancel' });
expect(parsed['/btn_save/i18n']).toEqual('Save changes');
expect(parsed['/btn_cancel/i18n']).toEqual('Cancel');
});
});

Expand Down Expand Up @@ -307,8 +306,8 @@ describe('structuralTransform', () => {

expect(result).not.toBeNull();
const extracted = JSON.parse(result!);
expect(extracted['/btn_save/i18n']).toEqual({ '': 'Guardar cambios' });
expect(extracted['/btn_cancel/i18n']).toEqual({ '': 'Cancelar' });
expect(extracted['/btn_save/i18n']).toEqual('Guardar cambios');
expect(extracted['/btn_cancel/i18n']).toEqual('Cancelar');
});
});
});
6 changes: 6 additions & 0 deletions packages/cli/src/formats/json/extractJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,12 @@ export function extractJson(
continue;
}

// If the source item is a string, use it directly
if (typeof matchingTargetItem.sourceItem === 'string') {
compositeResult[sourceObjectPointer] = matchingTargetItem.sourceItem;
continue;
}

// Extract values at the include paths
const extractedValues = flattenJsonWithStringFilter(
matchingTargetItem.sourceItem,
Expand Down
29 changes: 22 additions & 7 deletions packages/cli/src/formats/json/mergeJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ export function mergeJson(
// Create a deep copy of the original JSON to avoid mutations
const mergedJson = structuredClone(originalJson);

// Pre-parse all target contents ONCE (avoid re-parsing per pointer)
const parsedTargets = targets.map((target) => ({
...target,
parsedContent: JSON.parse(target.translatedContent),
}));

// Create mapping of sourceObjectPointer to SourceObjectOptions
const sourceObjectPointers: Record<
string,
Expand Down Expand Up @@ -145,9 +151,8 @@ export function mergeJson(
// 10. Remove all items for the target locale (they can be identified by the key)
const indiciesToRemove = new Set<number>();
const itemsToAdd: any[] = [];
for (const target of targets) {
const targetJson = JSON.parse(target.translatedContent);
let targetItems = targetJson[sourceObjectPointer];
for (const target of parsedTargets) {
let targetItems = target.parsedContent[sourceObjectPointer];
// 1. Get the target items
if (!targetItems) {
// If no translation can be found, a transformation may need to happen still
Expand Down Expand Up @@ -324,10 +329,9 @@ export function mergeJson(
// 5. Override the source item with the translated values
// 6. Apply additional mutations to the sourceItem
// 7. Merge the source item with the original JSON (if the source item is not a new item)
for (const target of targets) {
const targetJson = JSON.parse(target.translatedContent);
for (const target of parsedTargets) {
// 1. Get the target items
let targetItems = targetJson[sourceObjectPointer];
let targetItems = target.parsedContent[sourceObjectPointer];
if (!targetItems) {
targetItems = {};
}
Expand All @@ -341,10 +345,21 @@ export function mergeJson(
sourceObjectOptions,
sourceObjectValue
);
const mutateSourceItemKey = matchingTargetItem.keyParentProperty;

// If the source item is a string, use the translated string directly
if (typeof defaultLocaleSourceItem === 'string') {
const translatedValue =
typeof targetItems === 'string'
? targetItems
: defaultLocaleSourceItem;
sourceObjectValue[mutateSourceItemKey] = translatedValue;
continue;
}
Comment on lines +351 to +357
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

targetItems replaced before string check

The !targetItems guard on line 331–333 replaces a falsy targetItems with {} before the new string path is reached. An empty-string translation ("") is falsy, so it gets silently overwritten with {}, causing typeof targetItems === 'string' to be false and the code to fall back to defaultLocaleSourceItem. This means an empty translated string in the source map (intentional or otherwise) is silently discarded and replaced with the default-locale value.

let targetItems = targetJson[sourceObjectPointer];
if (!targetItems) {
  targetItems = {};
}

Consider tightening the guard to only replace null/undefined, not empty strings:

Suggested change
if (typeof defaultLocaleSourceItem === 'string') {
const translatedValue =
typeof targetItems === 'string'
? targetItems
: defaultLocaleSourceItem;
sourceObjectValue[mutateSourceItemKey] = translatedValue;
continue;
}
// If the source item is a string, use the translated string directly
if (typeof defaultLocaleSourceItem === 'string') {
const translatedValue =
typeof targetItems === 'string'
? targetItems
: defaultLocaleSourceItem;
sourceObjectValue[mutateSourceItemKey] = translatedValue;
continue;
}

The real fix belongs at the !targetItems initialisation (line 331):

// Before (replaces "" with {}):
if (!targetItems) { targetItems = {}; }

// After (only replaces null/undefined):
if (targetItems == null) { targetItems = {}; }

This keeps "" as a string so the downstream typeof targetItems === 'string' check works correctly.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/cli/src/formats/json/mergeJson.ts
Line: 347-354

Comment:
**`targetItems` replaced before string check**

The `!targetItems` guard on line 331–333 replaces a falsy `targetItems` with `{}` before the new string path is reached. An empty-string translation (`""`) is falsy, so it gets silently overwritten with `{}`, causing `typeof targetItems === 'string'` to be `false` and the code to fall back to `defaultLocaleSourceItem`. This means an empty translated string in the source map (intentional or otherwise) is silently discarded and replaced with the default-locale value.

```ts
let targetItems = targetJson[sourceObjectPointer];
if (!targetItems) {
  targetItems = {};
}
```

Consider tightening the guard to only replace `null`/`undefined`, not empty strings:

```suggestion
        // If the source item is a string, use the translated string directly
        if (typeof defaultLocaleSourceItem === 'string') {
          const translatedValue =
            typeof targetItems === 'string'
              ? targetItems
              : defaultLocaleSourceItem;
          sourceObjectValue[mutateSourceItemKey] = translatedValue;
          continue;
        }
```

The real fix belongs at the `!targetItems` initialisation (line 331):
```ts
// Before (replaces "" with {}):
if (!targetItems) { targetItems = {}; }

// After (only replaces null/undefined):
if (targetItems == null) { targetItems = {}; }
```
This keeps `""` as a string so the downstream `typeof targetItems === 'string'` check works correctly.

How can I resolve this? If you propose a fix, please make it concise.


// If the target locale has a matching source item, use it to mutate the source item
// Otherwise, fallback to the default locale source item
const mutateSourceItem = structuredClone(defaultLocaleSourceItem);
const mutateSourceItemKey = matchingTargetItem.keyParentProperty;

// 3. Merge the target items with the source item (if there are transformations to perform)
const mergedItems = {
Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/formats/json/parseJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export function parseJson(
// Construct lvl 2
const sourceObjectsToTranslate: Record<
string,
Record<string, Record<string, string>>
Record<string, Record<string, string>> | string
> = {};
Comment on lines 63 to 66
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No tests for new string handling

There are no test cases in parseJson.test.ts, mergeJson.test.ts, or extractJson.test.ts covering the new string-value composite path (e.g. { translations: { en: "Hello", fr: "Bonjour" } }). Since this PR is specifically fixing a bug in this code path, a regression test covering the full parse → merge → extract round-trip for string-valued composite schemas would significantly improve confidence in the fix.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/cli/src/formats/json/parseJson.ts
Line: 63-66

Comment:
**No tests for new string handling**

There are no test cases in `parseJson.test.ts`, `mergeJson.test.ts`, or `extractJson.test.ts` covering the new string-value composite path (e.g. `{ translations: { en: "Hello", fr: "Bonjour" } }`). Since this PR is specifically fixing a bug in this code path, a regression test covering the full parse → merge → extract round-trip for string-valued composite schemas would significantly improve confidence in the fix.

How can I resolve this? If you propose a fix, please make it concise.

for (const [
sourceObjectPointer,
Expand Down Expand Up @@ -163,6 +163,12 @@ export function parseJson(
}
const { sourceItem } = matchingItem;

// If the source item is a string, use it directly
if (typeof sourceItem === 'string') {
sourceObjectsToTranslate[sourceObjectPointer] = sourceItem;
continue;
}

// Get the fields to translate from the includes
const flatten = filterStrings ? flattenJsonWithStringFilter : flattenJson;
const itemsToTranslate = flatten(sourceItem, sourceObjectOptions.include);
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/generated/version.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
// This file is auto-generated. Do not edit manually.
export const PACKAGE_VERSION = '2.10.0';
export const PACKAGE_VERSION = '2.10.1';
Loading