Skip to content

Commit c3881c3

Browse files
authored
feat(cli): support multiple locales in path (#490)
1 parent b8a66af commit c3881c3

File tree

7 files changed

+117
-25
lines changed

7 files changed

+117
-25
lines changed

.changeset/polite-candles-brush.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"lingo.dev": minor
3+
---
4+
5+
support multiple [locale] placeholders in bucket path

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,26 @@ describe("bucket loaders", () => {
404404

405405
expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.json", expectedOutput, { encoding: "utf-8", flag: "w" });
406406
});
407+
408+
it("should load and save json data for paths with multiple locales", async () => {
409+
setupFileMocks();
410+
411+
const input = { "button.title": "Submit" };
412+
const payload = { "button.title": "Enviar" };
413+
const expectedOutput = JSON.stringify(payload, null, 2);
414+
415+
mockFileOperations(JSON.stringify(input));
416+
417+
const jsonLoader = createBucketLoader("json", "i18n/[locale]/[locale].json");
418+
jsonLoader.setDefaultLocale("en");
419+
const data = await jsonLoader.pull("en");
420+
421+
await jsonLoader.push("es", payload);
422+
423+
expect(data).toEqual(input);
424+
expect(fs.access).toHaveBeenCalledWith("i18n/en/en.json");
425+
expect(fs.writeFile).toHaveBeenCalledWith("i18n/es/es.json", expectedOutput, { encoding: "utf-8", flag: "w" });
426+
});
407427
});
408428

409429
describe("markdown bucket loader", () => {

packages/cli/src/cli/loaders/text-file.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export default function createTextFileLoader(pathPattern: string): ILoader<void,
1111
return trimmedResult;
1212
},
1313
async push(locale, data, _, originalLocale) {
14-
const draftPath = pathPattern.replace("[locale]", locale);
14+
const draftPath = pathPattern.replaceAll("[locale]", locale);
1515
const finalPath = path.resolve(draftPath);
1616

1717
// Create parent directories if needed
@@ -33,7 +33,7 @@ export default function createTextFileLoader(pathPattern: string): ILoader<void,
3333
}
3434

3535
async function readFileForLocale(pathPattern: string, locale: string) {
36-
const draftPath = pathPattern.replace("[locale]", locale);
36+
const draftPath = pathPattern.replaceAll("[locale]", locale);
3737
const finalPath = path.resolve(draftPath);
3838
const exists = await fs
3939
.access(finalPath)
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import { getBuckets } from "./buckets";
3+
4+
vi.mock("glob", () => ({
5+
sync: vi.fn().mockImplementation((path) => [{ isFile: () => true, fullpath: () => path }]),
6+
}));
7+
8+
describe("getBuckets", () => {
9+
const makeI18nConfig = (include: any[]) => ({
10+
version: 0,
11+
locale: {
12+
source: "en",
13+
targets: ["fr", "es"],
14+
},
15+
buckets: {
16+
json: {
17+
include,
18+
},
19+
},
20+
});
21+
22+
it("should return correct buckets", () => {
23+
const i18nConfig = makeI18nConfig(["src/i18n/[locale].json", "src/translations/[locale]/messages.json"]);
24+
const buckets = getBuckets(i18nConfig);
25+
expect(buckets).toEqual([
26+
{
27+
type: "json",
28+
config: [
29+
{ pathPattern: "src/i18n/[locale].json", delimiter: null },
30+
{ pathPattern: "src/translations/[locale]/messages.json", delimiter: null },
31+
],
32+
},
33+
]);
34+
});
35+
36+
it("should return correct bucket with delimiter", () => {
37+
const i18nConfig = makeI18nConfig([{ path: "src/i18n/[locale].json", delimiter: "-" }]);
38+
const buckets = getBuckets(i18nConfig);
39+
expect(buckets).toEqual([{ type: "json", config: [{ pathPattern: "src/i18n/[locale].json", delimiter: "-" }] }]);
40+
});
41+
42+
it("should return bucket with multiple locale placeholders", () => {
43+
const i18nConfig = makeI18nConfig([
44+
"src/i18n/[locale]/[locale].json",
45+
"src/[locale]/translations/[locale]/messages.json",
46+
]);
47+
const buckets = getBuckets(i18nConfig);
48+
expect(buckets).toEqual([
49+
{
50+
type: "json",
51+
config: [
52+
{ pathPattern: "src/i18n/[locale]/[locale].json", delimiter: null },
53+
{ pathPattern: "src/[locale]/translations/[locale]/messages.json", delimiter: null },
54+
],
55+
},
56+
]);
57+
});
58+
});

packages/cli/src/cli/utils/buckets.ts

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -59,21 +59,18 @@ function expandPlaceholderedGlob(_pathPattern: string, sourceLocale: string): st
5959
docUrl: "invalidPathPattern",
6060
});
6161
}
62-
// Throw error if pathPattern contains "[locale]" several times
63-
if (pathPattern.split("[locale]").length > 2) {
64-
throw new CLIError({
65-
message: `Invalid path pattern: ${pathPattern}. Path pattern must contain at most one "[locale]" placeholder.`,
66-
docUrl: "invalidPathPattern",
67-
});
68-
}
62+
6963
// Break down path pattern into parts
7064
const pathPatternChunks = pathPattern.split(path.sep);
7165
// Find the index of the segment containing "[locale]"
72-
const localeSegmentIndex = pathPatternChunks.findIndex((segment) => segment.includes("[locale]"));
73-
// Find the position of the "[locale]" placeholder within the segment
74-
const localePlaceholderIndex = pathPatternChunks[localeSegmentIndex]?.indexOf("[locale]") ?? -1;
66+
const localeSegmentIndexes = pathPatternChunks.reduce((indexes, segment, index) => {
67+
if (segment.includes("[locale]")) {
68+
indexes.push(index);
69+
}
70+
return indexes;
71+
}, [] as number[]);
7572
// substitute [locale] in pathPattern with sourceLocale
76-
const sourcePathPattern = pathPattern.replace(/\[locale\]/g, sourceLocale);
73+
const sourcePathPattern = pathPattern.replaceAll(/\[locale\]/g, sourceLocale);
7774
// get all files that match the sourcePathPattern
7875
const sourcePaths = glob
7976
.sync(sourcePathPattern, { follow: true, withFileTypes: true })
@@ -83,14 +80,18 @@ function expandPlaceholderedGlob(_pathPattern: string, sourceLocale: string): st
8380
// transform each source file path back to [locale] placeholder paths
8481
const placeholderedPaths = sourcePaths.map((sourcePath) => {
8582
const sourcePathChunks = sourcePath.split(path.sep);
86-
if (localeSegmentIndex >= 0 && localePlaceholderIndex >= 0) {
87-
const placeholderedPathChunk = sourcePathChunks[localeSegmentIndex];
88-
const placeholderedSegment =
89-
placeholderedPathChunk.substring(0, localePlaceholderIndex) +
90-
"[locale]" +
91-
placeholderedPathChunk.substring(localePlaceholderIndex + sourceLocale.length);
92-
sourcePathChunks[localeSegmentIndex] = placeholderedSegment;
93-
}
83+
localeSegmentIndexes.forEach((localeSegmentIndex) => {
84+
// Find the position of the "[locale]" placeholder within the segment
85+
const localePlaceholderIndex = pathPatternChunks[localeSegmentIndex]?.indexOf("[locale]") ?? -1;
86+
if (localeSegmentIndex >= 0 && localePlaceholderIndex >= 0) {
87+
const placeholderedPathChunk = sourcePathChunks[localeSegmentIndex];
88+
const placeholderedSegment =
89+
placeholderedPathChunk.substring(0, localePlaceholderIndex) +
90+
"[locale]" +
91+
placeholderedPathChunk.substring(localePlaceholderIndex + sourceLocale.length);
92+
sourcePathChunks[localeSegmentIndex] = placeholderedSegment;
93+
}
94+
});
9495
const placeholderedPath = sourcePathChunks.join(path.sep);
9596
return placeholderedPath;
9697
});

packages/cli/src/cli/utils/find-locale-paths.spec.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ describe("findLocaleFiles", () => {
6767
"src/aa/bb/foobar/cc/translations/es/values.json",
6868
"src/aa/en.json",
6969
"src/aa/translations/bb/en.json",
70+
"foo/en-US/en-US.json",
71+
"foo/en-US/en-US/messages.json",
72+
"bar/es/baz/es.json",
73+
"bar/es/es.json",
7074

7175
// not a valid locale
7276
"src/xx/settings.json",
@@ -85,6 +89,10 @@ describe("findLocaleFiles", () => {
8589
"src/aa/bb/foobar/cc/translations/[locale]/values.json",
8690
"src/aa/[locale].json",
8791
"src/aa/translations/bb/[locale].json",
92+
"foo/[locale]/[locale].json",
93+
"foo/[locale]/[locale]/messages.json",
94+
"bar/[locale]/baz/[locale].json",
95+
"bar/[locale]/[locale].json",
8896
],
8997
});
9098
});

packages/cli/src/cli/utils/find-locale-paths.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,10 @@ function findLocaleFilesWithExtension(ext: string) {
6060
.filter(({ locale }) => locale !== null);
6161

6262
const localeFilesAndPatterns = potantialLocaleFilesAndPatterns.map(({ file, locale }) => {
63-
const localeInDir = file.match(`/${locale}/`);
64-
const pattern = localeInDir
65-
? file.replace(`/${locale}/`, `/[locale]/`)
66-
: path.join(path.dirname(file), `[locale]${ext}`);
63+
const pattern = file
64+
.replaceAll(new RegExp(`/${locale}${ext}`, "g"), `/[locale]${ext}`)
65+
.replaceAll(new RegExp(`/${locale}/`, "g"), `/[locale]/`)
66+
.replaceAll(new RegExp(`/${locale}/`, "g"), `/[locale]/`); // for when there are 2 locales one after another
6767
return { pattern, file };
6868
});
6969

0 commit comments

Comments
 (0)