Skip to content

Commit f643c28

Browse files
fix: ascii-ordered xcstrings (#437)
* feat: ascii-ordered xcstrings * chore: add changeset
1 parent 344ef54 commit f643c28

9 files changed

Lines changed: 241 additions & 24 deletions

File tree

.changeset/tasty-goats-know.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"lingo.dev": patch
3+
---
4+
5+
order xcstrings ascii-way

packages/cli/demo/xcode-xcstrings/Localizable.xcstrings

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,77 @@
11
{
22
"sourceLanguage" : "en",
33
"strings" : {
4-
"complex_format" : {
4+
"" : {
5+
6+
},
7+
" " : {
8+
9+
},
10+
" and " : {
511
"extractionState" : "manual",
612
"localizations" : {
713
"ar" : {
814
"stringUnit" : {
915
"state" : "translated",
10-
"value" : "المستخدم %1$@ لديه %2$lld نقطة ورصيد $%3$.2f"
16+
"value" : " و "
1117
}
1218
},
1319
"en" : {
1420
"stringUnit" : {
1521
"state" : "translated",
16-
"value" : "User %1$@ has %2$lld points and $%3$.2f balance"
22+
"value" : " and "
23+
}
24+
},
25+
"es" : {
26+
"stringUnit" : {
27+
"state" : "translated",
28+
"value" : " y "
29+
}
30+
}
31+
}
32+
},
33+
"25" : {
34+
"extractionState" : "manual",
35+
"localizations" : {
36+
"ar" : {
37+
"stringUnit" : {
38+
"state" : "translated",
39+
"value" : "25"
40+
}
41+
},
42+
"en" : {
43+
"stringUnit" : {
44+
"state" : "translated",
45+
"value" : "25"
46+
}
47+
},
48+
"es" : {
49+
"stringUnit" : {
50+
"state" : "translated",
51+
"value" : "25"
52+
}
53+
}
54+
}
55+
},
56+
"apple" : {
57+
"extractionState" : "manual",
58+
"localizations" : {
59+
"ar" : {
60+
"stringUnit" : {
61+
"state" : "translated",
62+
"value" : "تفاحة"
63+
}
64+
},
65+
"en" : {
66+
"stringUnit" : {
67+
"state" : "translated",
68+
"value" : "apple"
69+
}
70+
},
71+
"es" : {
72+
"stringUnit" : {
73+
"state" : "translated",
74+
"value" : "manzana"
1775
}
1876
}
1977
}

packages/cli/i18n.lock

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,8 @@ checksums:
129129
date_format: c4137fde368529804b9c43edb293f521
130130
multiline_html: a2f5d47387c8011c0dccc58ceaa1cb8c
131131
250a4a9b39c8b90557bd4f761e5f3cbb:
132-
complex_format: 3dd10711bade5a656b9b47586ab27b9b
132+
"%20and%20": 05dc0a811ee5b04941c826293471367d
133+
apple: 263c986f157e7e030f8a69693c158cb5
133134
b05b783b3d24ee943d83ccd8e274814d:
134135
home/title%2Fmain: ef1fb56b8dafd605fb267df2b0a8a91f
135136
home/description%2Fdev: b866f4d6b135451633de827fc20f4fdb
@@ -340,3 +341,8 @@ checksums:
340341
cg3SetXtQ9WygXv_F_cGDw/_EagEKQA4RwmdrSfH8B4uOw/platform_pricing_plans/3/description: 66aa3ced3589f3d16c7b4e2421ce08bd
341342
cg3SetXtQ9WygXv_F_cGDw/_EagEKQA4RwmdrSfH8B4uOw/platform_pricing_plans/3/pricing_features_intro: b02621d78eb9edb2c74878fb22fb5294
342343
cg3SetXtQ9WygXv_F_cGDw/_EagEKQA4RwmdrSfH8B4uOw/platform_pricing_plans/3/cta/0/content: 694a45bc5c96f6f3a27873c1cf5a7707
344+
829cd6f9351a1ca290ee8532089bc1c8:
345+
complex_format: 3dd10711bade5a656b9b47586ab27b9b
346+
862ce219de509da76aa68a92433210a2:
347+
"%20and%20": 05dc0a811ee5b04941c826293471367d
348+
apple: 263c986f157e7e030f8a69693c158cb5

packages/cli/src/cli/loaders/index.spec.ts

Lines changed: 137 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -635,7 +635,7 @@ user.password=Contraseña
635635
"en" : {
636636
"stringUnit" : {
637637
"state" : "translated",
638-
"value" : "Hello!",
638+
"value" : "Hello!"
639639
},
640640
},
641641
},
@@ -757,6 +757,141 @@ user.password=Contraseña
757757
flag: "w",
758758
});
759759
});
760+
761+
it("should maintain ASCII ordering with whitespace and special characters", async () => {
762+
setupFileMocks();
763+
764+
const input = `{
765+
"sourceLanguage" : "en",
766+
"strings" : {
767+
"" : {
768+
769+
},
770+
" " : {
771+
772+
},
773+
" and " : {
774+
"extractionState" : "manual",
775+
"localizations" : {
776+
"en" : {
777+
"stringUnit" : {
778+
"state" : "translated",
779+
"value" : " and "
780+
}
781+
},
782+
}
783+
},
784+
"25" : {
785+
"extractionState" : "manual",
786+
"localizations" : {
787+
"en" : {
788+
"stringUnit" : {
789+
"state" : "translated",
790+
"value" : "25"
791+
}
792+
}
793+
}
794+
},
795+
"apple" : {
796+
"extractionState" : "manual",
797+
"localizations" : {
798+
"en" : {
799+
"stringUnit" : {
800+
"state" : "translated",
801+
"value" : "apple"
802+
}
803+
}
804+
}
805+
}
806+
},
807+
"version" : "1.0"
808+
}`.trim();
809+
810+
const payloadAr = {
811+
"": "",
812+
" ": "",
813+
"%20and%20": " و ",
814+
"25": "25",
815+
apple: "تفاحة",
816+
};
817+
818+
const expectedOutput = `{
819+
"sourceLanguage" : "en",
820+
"strings" : {
821+
"" : {
822+
823+
},
824+
" " : {
825+
826+
},
827+
" and " : {
828+
"extractionState" : "manual",
829+
"localizations" : {
830+
"ar" : {
831+
"stringUnit" : {
832+
"state" : "translated",
833+
"value" : " و "
834+
}
835+
},
836+
"en" : {
837+
"stringUnit" : {
838+
"state" : "translated",
839+
"value" : " and "
840+
}
841+
}
842+
}
843+
},
844+
"25" : {
845+
"extractionState" : "manual",
846+
"localizations" : {
847+
"ar" : {
848+
"stringUnit" : {
849+
"state" : "translated",
850+
"value" : "25"
851+
}
852+
},
853+
"en" : {
854+
"stringUnit" : {
855+
"state" : "translated",
856+
"value" : "25"
857+
}
858+
}
859+
}
860+
},
861+
"apple" : {
862+
"extractionState" : "manual",
863+
"localizations" : {
864+
"ar" : {
865+
"stringUnit" : {
866+
"state" : "translated",
867+
"value" : "تفاحة"
868+
}
869+
},
870+
"en" : {
871+
"stringUnit" : {
872+
"state" : "translated",
873+
"value" : "apple"
874+
}
875+
}
876+
}
877+
}
878+
},
879+
"version" : "1.0"
880+
}`.trim();
881+
882+
mockFileOperations(input);
883+
884+
const xcodeXcstringsLoader = createBucketLoader("xcode-xcstrings", "Localizable.xcstrings");
885+
xcodeXcstringsLoader.setDefaultLocale("en");
886+
await xcodeXcstringsLoader.pull("en");
887+
888+
await xcodeXcstringsLoader.push("ar", payloadAr);
889+
890+
expect(fs.writeFile).toHaveBeenCalledWith("Localizable.xcstrings", expectedOutput, {
891+
encoding: "utf-8",
892+
flag: "w",
893+
});
894+
});
760895
});
761896

762897
describe("yaml bucket loader", () => {
@@ -1007,10 +1142,7 @@ Bar`.trim();
10071142

10081143
await xmlLoader.push("es", payload);
10091144

1010-
expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.xml", expectedOutput, {
1011-
encoding: "utf-8",
1012-
flag: "w",
1013-
});
1145+
expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.xml", expectedOutput, { encoding: "utf-8", flag: "w" });
10141146
});
10151147
});
10161148

packages/cli/src/cli/loaders/index.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ import createVariableLoader from "./variable";
2828
import createSyncLoader from "./sync";
2929
import createPlutilJsonTextLoader from "./plutil-json-loader";
3030
import createNewLineLoader from "./new-line";
31-
import createJsonSortingLoader from "./json-sorting";
3231

3332
export default function createBucketLoader(
3433
bucketType: Z.infer<typeof bucketTypeSchema>,
@@ -124,7 +123,6 @@ export default function createBucketLoader(
124123
createNewLineLoader(),
125124
createPlutilJsonTextLoader(),
126125
createJsonLoader(),
127-
createJsonSortingLoader(),
128126
createXcodeXcstringsLoader(),
129127
createFlatLoader(),
130128
createSyncLoader(),

packages/cli/src/cli/loaders/json-sorting.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ function sortObjectDeep(obj: any): any {
1919

2020
if (obj !== null && typeof obj === "object") {
2121
return Object.keys(obj)
22-
.sort()
22+
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
2323
.reduce((result: any, key) => {
2424
result[key] = sortObjectDeep(obj[key]);
2525
return result;

packages/cli/src/cli/loaders/plutil-json-loader.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@ export default function createPlutilJsonTextLoader(): ILoader<string, string> {
1010
async push(locale, data, originalInput) {
1111
const jsonData = JSON.parse(data);
1212
const result = formatPlutilStyle(jsonData, originalInput || "");
13-
// printout last symbol
14-
console.log(result[result.length - 1]);
13+
1514
return result;
1615
},
1716
});

packages/cli/src/cli/loaders/unlocalizable.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,25 @@ import { ILoader } from "./_types";
66
import { createLoader } from "./_utils";
77

88
export default function createUnlocalizableLoader(): ILoader<Record<string, any>, Record<string, any>> {
9+
const rules = {
10+
isEmpty: (v: any) => _.isEmpty(v),
11+
isNumber: (v: string) => !_.isNaN(_.toNumber(v)),
12+
isBoolean: (v: string) => _.isBoolean(v),
13+
isIsoDate: (v: string) => _.isString(v) && _isIsoDate(v),
14+
isSystemId: (v: string) => _.isString(v) && _isSystemId(v),
15+
isUrl: (v: string) => _.isString(v) && _isUrl(v),
16+
};
917
return createLoader({
1018
async pull(locale, input) {
1119
const passthroughKeys = Object.entries(input)
1220
.filter(([key, value]) => {
13-
return [
14-
(v: any) => _.isEmpty(v),
15-
(v: string) => _.isString(v) && _isIsoDate(v),
16-
(v: string) => !_.isNaN(_.toNumber(v)),
17-
(v: string) => _.isBoolean(v),
18-
(v: string) => _.isString(v) && _isSystemId(v),
19-
(v: string) => _.isString(v) && _isUrl(v),
20-
].some((fn) => fn(value));
21+
// Check each rule individually for better debugging
22+
for (const [ruleName, rule] of Object.entries(rules)) {
23+
if (rule(value)) {
24+
return true;
25+
}
26+
}
27+
return false;
2128
})
2229
.map(([key, _]) => key);
2330

packages/cli/src/cli/utils/plutil-formatter.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,27 @@ export function formatPlutilStyle(jsonData: any, existingJson?: string): string
2121
return `{\n\n${currentIndent}}`; // Empty object with proper indentation
2222
}
2323

24-
const items = keys.map((key) => {
24+
// Sort keys to ensure whitespace keys come first
25+
const sortedKeys = keys.sort((a, b) => {
26+
// If both keys are whitespace or both are non-whitespace, maintain stable order
27+
const aIsWhitespace = /^\s*$/.test(a);
28+
const bIsWhitespace = /^\s*$/.test(b);
29+
30+
if (aIsWhitespace && !bIsWhitespace) return -1;
31+
if (!aIsWhitespace && bIsWhitespace) return 1;
32+
return a.localeCompare(b, undefined, { numeric: false });
33+
});
34+
35+
const items = sortedKeys.map((key) => {
2536
const value = data[key];
2637
return `${nextIndent}${JSON.stringify(key)} : ${format(value, level + 1)}`;
2738
});
2839

2940
return `{\n${items.join(",\n")}\n${currentIndent}}`;
3041
}
3142

32-
return format(jsonData);
43+
const result = format(jsonData);
44+
return result;
3345
}
3446

3547
function detectIndentation(jsonStr: string): string {

0 commit comments

Comments
 (0)