Skip to content

Commit 3d21698

Browse files
authored
feat(cli): detect paths for existing locale files for each bucket (#477)
1 parent a430258 commit 3d21698

5 files changed

Lines changed: 345 additions & 8 deletions

File tree

.changeset/cool-roses-notice.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+
detect paths for existing locale files for each bucket during init

packages/cli/src/cli/cmd/init.ts

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ import fs from "fs";
66
import path from "path";
77
import { spawn } from "child_process";
88
import _ from "lodash";
9-
import { confirm } from "@inquirer/prompts";
9+
import { checkbox, confirm, input } from "@inquirer/prompts";
1010
import { login } from "./auth";
1111
import { getSettings, saveSettings } from "../utils/settings";
1212
import { createAuthenticator } from "../utils/auth";
13+
import findLocaleFiles from "../utils/find-locale-paths";
14+
import { ensurePatterns } from "../utils/ensure-patterns";
1315

1416
const openUrl = (path: string) => {
1517
const settings = getSettings(undefined);
@@ -87,10 +89,12 @@ export default new InteractiveCommand()
8789

8890
return values;
8991
})
92+
.prompt(undefined) // make non-interactive
9093
.default([]),
9194
)
9295
.action(async (options) => {
9396
const settings = getSettings(undefined);
97+
const isInteractive = options.interactive;
9498

9599
const spinner = Ora().start("Initializing Lingo.dev project");
96100

@@ -104,18 +108,55 @@ export default new InteractiveCommand()
104108

105109
newConfig.locale.source = options.source;
106110
newConfig.locale.targets = options.targets;
107-
newConfig.buckets = {
108-
[options.bucket]: {
109-
include: options.paths || [],
110-
},
111-
};
111+
112+
if (!isInteractive) {
113+
newConfig.buckets = {
114+
[options.bucket]: {
115+
include: options.paths || [],
116+
},
117+
};
118+
} else {
119+
let selectedPatterns: string[] = [];
120+
const { found, patterns } = findLocaleFiles(options.bucket);
121+
122+
if (found) {
123+
spinner.succeed("Found existing locale files:");
124+
125+
selectedPatterns = await checkbox({
126+
message: "Select the paths to use",
127+
choices: patterns.map((value) => ({
128+
value,
129+
})),
130+
});
131+
} else {
132+
spinner.succeed("No existing locale files found.");
133+
const useDefault = await confirm({
134+
message: `Use default path ${patterns.join(", ")}?`,
135+
});
136+
ensurePatterns(patterns, options.source);
137+
if (useDefault) {
138+
selectedPatterns = patterns;
139+
}
140+
}
141+
142+
if (selectedPatterns.length === 0) {
143+
const customPaths = await input({
144+
message: "Enter paths to use",
145+
});
146+
selectedPatterns = customPaths.includes(",") ? customPaths.split(",") : customPaths.split(" ");
147+
}
148+
149+
newConfig.buckets = {
150+
[options.bucket]: {
151+
include: selectedPatterns || [],
152+
},
153+
};
154+
}
112155

113156
await saveConfig(newConfig);
114157

115158
spinner.succeed("Lingo.dev project initialized");
116159

117-
const isInteractive = !process.argv.includes("-y") && !process.argv.includes("--no-interactive");
118-
119160
if (isInteractive) {
120161
const openDocs = await confirm({ message: "Would you like to see our docs?" });
121162
if (openDocs) {
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import fs from "fs";
2+
import path from "path";
3+
import { glob } from "glob";
4+
import _ from "lodash";
5+
import { LocaleCode, resolveLocaleCode } from "@lingo.dev/_spec";
6+
7+
export function ensurePatterns(patterns: string[], source: string) {
8+
if (patterns.length === 0) {
9+
throw new Error("No patterns found");
10+
}
11+
12+
patterns.forEach((pattern) => {
13+
const filePath = pattern.replace("[locale]", source);
14+
if (!fs.existsSync(filePath)) {
15+
const defaultContent = getDefaultContent(path.extname(filePath), source);
16+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
17+
fs.writeFileSync(filePath, defaultContent);
18+
}
19+
});
20+
}
21+
22+
function getDefaultContent(ext: string, source: string) {
23+
const defaultGreeting = "Hello from Lingo.dev";
24+
switch (ext) {
25+
case ".json":
26+
case ".arb":
27+
return `{\n\t"greeting": "${defaultGreeting}"\n}`;
28+
case ".yml":
29+
return `${source}:\n\tgreeting: "${defaultGreeting}"`;
30+
case ".xml":
31+
return `<resources>\n\t<string name="greeting">${defaultGreeting}</string>\n</resources>`;
32+
case ".md":
33+
return `# ${defaultGreeting}`;
34+
case ".xcstrings":
35+
return `{
36+
"sourceLanguage" : "${source}",
37+
"strings" : {
38+
"${defaultGreeting}" : {
39+
"extractionState" : "manual",
40+
"localizations" : {
41+
"${source}" : {
42+
"stringUnit" : {
43+
"state" : "translated",
44+
"value" : "${defaultGreeting}"
45+
}
46+
}
47+
}
48+
}
49+
}
50+
}`;
51+
case ".strings":
52+
return `"greeting" = "${defaultGreeting}";`;
53+
case ".stringsdict":
54+
return `<?xml version="1.0" encoding="UTF-8"?>
55+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
56+
<plist version="1.0">
57+
<dict>
58+
<key>key</key>
59+
<dict>
60+
<key>NSStringLocalizedFormatKey</key>
61+
<string>%#@count@</string>
62+
<key>count</key>
63+
<dict>
64+
<key>NSStringFormatSpecTypeKey</key>
65+
<string>NSStringPluralRuleType</string>
66+
<key>NSStringFormatValueTypeKey</key>
67+
<string>d</string>
68+
<key>zero</key>
69+
<string>No items</string>
70+
<key>one</key>
71+
<string>One item</string>
72+
<key>other</key>
73+
<string>%d items</string>
74+
</dict>
75+
</dict>
76+
</dict>
77+
</plist>`;
78+
default:
79+
throw new Error(`Unsupported file extension: ${ext}`);
80+
}
81+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { glob } from "glob";
3+
import findLocaleFiles from "./find-locale-paths";
4+
5+
vi.mock("glob", () => ({
6+
glob: {
7+
sync: vi.fn(),
8+
},
9+
}));
10+
11+
describe("findLocaleFiles", () => {
12+
beforeEach(() => {
13+
vi.clearAllMocks();
14+
});
15+
16+
it("should find json locale files", () => {
17+
vi.mocked(glob.sync).mockReturnValue([
18+
// valid locales
19+
"src/i18n/en.json",
20+
"src/i18n/fr.json",
21+
"src/i18n/en-US.json",
22+
"src/translations/es.json",
23+
24+
// not a valid locale
25+
"src/xx.json",
26+
"src/settings.json",
27+
]);
28+
29+
const result = findLocaleFiles("json");
30+
31+
expect(result).toEqual({
32+
found: true,
33+
patterns: ["src/i18n/[locale].json", "src/translations/[locale].json"],
34+
});
35+
});
36+
37+
it("should find yaml locale files", () => {
38+
vi.mocked(glob.sync).mockReturnValue(["locales/en.yml", "locales/fr.yml", "translations/es.yml"]);
39+
40+
const result = findLocaleFiles("yaml");
41+
42+
expect(result).toEqual({
43+
found: true,
44+
patterns: ["locales/[locale].yml", "translations/[locale].yml"],
45+
});
46+
});
47+
48+
it("should find flutter arb locale files", () => {
49+
vi.mocked(glob.sync).mockReturnValue(["lib/l10n/en.arb", "lib/l10n/es.arb", "lib/translations/fr.arb"]);
50+
51+
const result = findLocaleFiles("flutter");
52+
53+
expect(result).toEqual({
54+
found: true,
55+
patterns: ["lib/l10n/[locale].arb", "lib/translations/[locale].arb"],
56+
});
57+
});
58+
59+
it("should find locale files in nested directories", () => {
60+
vi.mocked(glob.sync).mockReturnValue([
61+
// valid locales
62+
"src/locales/en/messages.json",
63+
"src/locales/fr/messages.json",
64+
"src/i18n/es/strings.json",
65+
"src/translations/es.json",
66+
67+
// not a valid locale
68+
"src/xx/settings.json",
69+
"src/xx.json",
70+
]);
71+
72+
const result = findLocaleFiles("json");
73+
74+
expect(result).toEqual({
75+
found: true,
76+
patterns: [
77+
"src/locales/[locale]/messages.json",
78+
"src/i18n/[locale]/strings.json",
79+
"src/translations/[locale].json",
80+
],
81+
});
82+
});
83+
84+
it("should return default pattern when no files found", () => {
85+
vi.mocked(glob.sync).mockReturnValue([]);
86+
87+
const result = findLocaleFiles("json");
88+
89+
expect(result).toEqual({
90+
found: false,
91+
patterns: ["i18n/[locale].json"],
92+
});
93+
});
94+
95+
it("should find xcode-xcstrings locale files", () => {
96+
vi.mocked(glob.sync).mockReturnValue([
97+
"ios/MyApp/Localizable.xcstrings",
98+
"ios/MyApp/Onboarding/Localizable.xcstrings",
99+
"ios/MyApp/Onboarding/fr.xcstrings",
100+
]);
101+
102+
const result = findLocaleFiles("xcode-xcstrings");
103+
104+
expect(result).toEqual({
105+
found: true,
106+
patterns: ["ios/MyApp/Localizable.xcstrings", "ios/MyApp/Onboarding/Localizable.xcstrings"],
107+
});
108+
});
109+
110+
it("should return default pattern for xcode-xcstrings when no files found", () => {
111+
vi.mocked(glob.sync).mockReturnValue([]);
112+
113+
const result = findLocaleFiles("xcode-xcstrings");
114+
115+
expect(result).toEqual({
116+
found: false,
117+
patterns: ["Localizable.xcstrings"],
118+
});
119+
});
120+
121+
it("should throw error for unsupported bucket type", () => {
122+
expect(() => findLocaleFiles("invalid")).toThrow("Unsupported bucket type: invalid");
123+
});
124+
});
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import fs from "fs";
2+
import path from "path";
3+
import { glob } from "glob";
4+
import _ from "lodash";
5+
import { LocaleCode, resolveLocaleCode } from "@lingo.dev/_spec";
6+
7+
export default function findLocaleFiles(bucket: string) {
8+
switch (bucket) {
9+
case "json":
10+
return findLocaleFilesWithExtension(".json");
11+
case "yaml":
12+
return findLocaleFilesWithExtension(".yml");
13+
case "flutter":
14+
return findLocaleFilesWithExtension(".arb");
15+
case "android":
16+
return findLocaleFilesWithExtension(".xml");
17+
case "markdown":
18+
return findLocaleFilesWithExtension(".md");
19+
case "xcode-xcstrings":
20+
return findLocaleFilesForFilename("Localizable.xcstrings");
21+
case "xcode-strings":
22+
return findLocaleFilesForFilename("Localizable.strings");
23+
case "xcode-stringsdict":
24+
return findLocaleFilesForFilename("Localizable.stringsdict");
25+
default:
26+
throw new Error(`Unsupported bucket type: ${bucket}`);
27+
}
28+
}
29+
30+
function findLocaleFilesWithExtension(ext: string) {
31+
const files = glob.sync(`**/*${ext}`, {
32+
ignore: ["node_modules/**", "package*.json", "i18n.json", "lingo.json"],
33+
});
34+
35+
const localeFilePattern = new RegExp(`[\/\\\\]([a-z]{2}(-[A-Z]{2})?)${ext}$`);
36+
const localeDirectoryPattern = new RegExp(`[\/\\\\]([a-z]{2}(-[A-Z]{2})?)[\/\\\\][^\/\\\\]+${ext}$`);
37+
const potentialLocaleFiles = files.filter(
38+
(file: string) => localeFilePattern.test(file) || localeDirectoryPattern.test(file),
39+
);
40+
const localeFilesAndPatterns = potentialLocaleFiles
41+
.map((file: string) => {
42+
const match = file.match(new RegExp(`[\/|\\\\]([a-z]{2}(-[A-Z]{2})?)(\/|\\\\|${ext})`));
43+
const locale = match?.[1];
44+
const localeInDir = match?.[3] !== ext;
45+
const filePattern = localeInDir
46+
? file.replace(`/${locale}/`, `/[locale]/`)
47+
: path.join(path.dirname(file), `[locale]${ext}`);
48+
return { file, locale, pattern: filePattern };
49+
})
50+
.filter(({ locale }) => {
51+
try {
52+
resolveLocaleCode(locale as LocaleCode);
53+
return true;
54+
} catch (e) {}
55+
return false;
56+
});
57+
58+
const grouppedFilesAndPatterns = _.groupBy(localeFilesAndPatterns, "pattern");
59+
const patterns = Object.keys(grouppedFilesAndPatterns);
60+
61+
if (patterns.length > 0) {
62+
return { found: true, patterns };
63+
}
64+
65+
return { found: false, patterns: [`i18n/[locale]${ext}`] };
66+
}
67+
68+
function findLocaleFilesForFilename(fileName: string) {
69+
const pattern = fileName;
70+
const localeFiles = glob.sync(`**/${fileName}`, {
71+
ignore: ["node_modules/**", "package*.json", "i18n.json", "lingo.json"],
72+
});
73+
74+
const localeFilesAndPatterns = localeFiles.map((file: string) => ({
75+
file,
76+
pattern: path.join(path.dirname(file), pattern),
77+
}));
78+
const grouppedFilesAndPatterns = _.groupBy(localeFilesAndPatterns, "pattern");
79+
const patterns = Object.keys(grouppedFilesAndPatterns);
80+
81+
if (patterns.length > 0) {
82+
return { found: true, patterns };
83+
}
84+
85+
return { found: false, patterns: [fileName] };
86+
}

0 commit comments

Comments
 (0)