Skip to content

Commit 26d2ec1

Browse files
authored
feat: add 'show locked-keys' and 'show ignored-keys' commands (#1218)
* feat: add 'show locked-keys' and 'show ignored-keys' commands * chore: add changeset * chore: fix formatting
1 parent 9f2eb17 commit 26d2ec1

File tree

9 files changed

+345
-19
lines changed

9 files changed

+345
-19
lines changed
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+
add 'show ignored-keys' and 'show locked-keys' commands
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { resolveOverriddenLocale, I18nConfig } from "@lingo.dev/_spec";
2+
import createBucketLoader from "../../loaders";
3+
import {
4+
matchesKeyPattern,
5+
formatDisplayValue,
6+
} from "../../utils/key-matching";
7+
8+
export type KeyFilterType = "lockedKeys" | "ignoredKeys";
9+
10+
export interface KeyCommandOptions {
11+
bucket?: string;
12+
}
13+
14+
export interface KeyCommandConfig {
15+
filterType: KeyFilterType;
16+
displayName: string; // e.g., "locked", "ignored"
17+
}
18+
19+
export async function executeKeyCommand(
20+
i18nConfig: I18nConfig,
21+
buckets: any[],
22+
options: KeyCommandOptions,
23+
config: KeyCommandConfig,
24+
): Promise<void> {
25+
let hasAnyKeys = false;
26+
27+
for (const bucket of buckets) {
28+
// Filter by bucket name if specified
29+
if (options.bucket && bucket.type !== options.bucket) {
30+
continue;
31+
}
32+
33+
// Skip buckets without the specified key patterns
34+
const keyPatterns = bucket[config.filterType];
35+
if (!keyPatterns || keyPatterns.length === 0) {
36+
continue;
37+
}
38+
39+
hasAnyKeys = true;
40+
41+
console.log(`\nBucket: ${bucket.type}`);
42+
console.log(
43+
`${capitalize(config.displayName)} key patterns: ${keyPatterns.join(", ")}`,
44+
);
45+
46+
for (const bucketConfig of bucket.paths) {
47+
const sourceLocale = resolveOverriddenLocale(
48+
i18nConfig.locale.source,
49+
bucketConfig.delimiter,
50+
);
51+
const sourcePath = bucketConfig.pathPattern.replace(
52+
/\[locale\]/g,
53+
sourceLocale,
54+
);
55+
56+
try {
57+
// Create a loader to read the source file
58+
const loader = createBucketLoader(
59+
bucket.type,
60+
bucketConfig.pathPattern,
61+
{
62+
defaultLocale: sourceLocale,
63+
injectLocale: bucket.injectLocale,
64+
},
65+
[], // Don't apply any filtering when reading
66+
[],
67+
[],
68+
);
69+
loader.setDefaultLocale(sourceLocale);
70+
71+
// Read the source file content
72+
const data = await loader.pull(sourceLocale);
73+
74+
if (!data || Object.keys(data).length === 0) {
75+
continue;
76+
}
77+
78+
// Filter keys that match the patterns
79+
const matchedEntries = Object.entries(data).filter(([key]) =>
80+
matchesKeyPattern(key, keyPatterns),
81+
);
82+
83+
if (matchedEntries.length > 0) {
84+
console.log(`\nMatches in ${sourcePath}:`);
85+
for (const [key, value] of matchedEntries) {
86+
const displayValue = formatDisplayValue(value);
87+
console.log(` - ${key}: ${displayValue}`);
88+
}
89+
console.log(
90+
`Total: ${matchedEntries.length} ${config.displayName} key(s)`,
91+
);
92+
}
93+
} catch (error: any) {
94+
console.error(` Error reading ${sourcePath}: ${error.message}`);
95+
}
96+
}
97+
}
98+
99+
if (!hasAnyKeys) {
100+
if (options.bucket) {
101+
console.log(
102+
`No ${config.displayName} keys configured for bucket: ${options.bucket}`,
103+
);
104+
} else {
105+
console.log(`No ${config.displayName} keys configured in any bucket.`);
106+
}
107+
}
108+
}
109+
110+
function capitalize(str: string): string {
111+
return str.charAt(0).toUpperCase() + str.slice(1);
112+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { Command } from "interactive-commander";
2+
import Ora from "ora";
3+
import { getConfig } from "../../utils/config";
4+
import { CLIError } from "../../utils/errors";
5+
import { getBuckets } from "../../utils/buckets";
6+
import { executeKeyCommand } from "./_shared-key-command";
7+
8+
export default new Command()
9+
.command("ignored-keys")
10+
.description(
11+
"Show which key-value pairs in source files match ignoredKeys patterns",
12+
)
13+
.option("--bucket <name>", "Only show ignored keys for a specific bucket")
14+
.helpOption("-h, --help", "Show help")
15+
.action(async (options) => {
16+
const ora = Ora();
17+
try {
18+
const i18nConfig = await getConfig();
19+
20+
if (!i18nConfig) {
21+
throw new CLIError({
22+
message:
23+
"i18n.json not found. Please run `lingo.dev init` to initialize the project.",
24+
docUrl: "i18nNotFound",
25+
});
26+
}
27+
28+
const buckets = getBuckets(i18nConfig);
29+
30+
await executeKeyCommand(i18nConfig, buckets, options, {
31+
filterType: "ignoredKeys",
32+
displayName: "ignored",
33+
});
34+
} catch (error: any) {
35+
ora.fail(error.message);
36+
process.exit(1);
37+
}
38+
});

packages/cli/src/cli/cmd/show/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@ import _ from "lodash";
33
import configCmd from "./config";
44
import localeCmd from "./locale";
55
import filesCmd from "./files";
6+
import lockedKeysCmd from "./locked-keys";
7+
import ignoredKeysCmd from "./ignored-keys";
68

79
export default new Command()
810
.command("show")
911
.description("Display configuration, locales, and file paths")
1012
.helpOption("-h, --help", "Show help")
1113
.addCommand(configCmd)
1214
.addCommand(localeCmd)
13-
.addCommand(filesCmd);
15+
.addCommand(filesCmd)
16+
.addCommand(lockedKeysCmd)
17+
.addCommand(ignoredKeysCmd);
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { Command } from "interactive-commander";
2+
import Ora from "ora";
3+
import { getConfig } from "../../utils/config";
4+
import { CLIError } from "../../utils/errors";
5+
import { getBuckets } from "../../utils/buckets";
6+
import { executeKeyCommand } from "./_shared-key-command";
7+
8+
export default new Command()
9+
.command("locked-keys")
10+
.description(
11+
"Show which key-value pairs in source files match lockedKeys patterns",
12+
)
13+
.option("--bucket <name>", "Only show locked keys for a specific bucket")
14+
.helpOption("-h, --help", "Show help")
15+
.action(async (options) => {
16+
const ora = Ora();
17+
try {
18+
const i18nConfig = await getConfig();
19+
20+
if (!i18nConfig) {
21+
throw new CLIError({
22+
message:
23+
"i18n.json not found. Please run `lingo.dev init` to initialize the project.",
24+
docUrl: "i18nNotFound",
25+
});
26+
}
27+
28+
const buckets = getBuckets(i18nConfig);
29+
30+
await executeKeyCommand(i18nConfig, buckets, options, {
31+
filterType: "lockedKeys",
32+
displayName: "locked",
33+
});
34+
} catch (error: any) {
35+
ora.fail(error.message);
36+
process.exit(1);
37+
}
38+
});
Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,24 @@
11
import { ILoader } from "./_types";
22
import { createLoader } from "./_utils";
33
import _ from "lodash";
4-
import { minimatch } from "minimatch";
4+
import { matchesKeyPattern } from "../utils/key-matching";
55

66
export default function createIgnoredKeysLoader(
77
ignoredKeys: string[],
88
): ILoader<Record<string, any>, Record<string, any>> {
99
return createLoader({
1010
pull: async (locale, data) => {
1111
const result = _.omitBy(data, (value, key) =>
12-
_isIgnoredKey(key, ignoredKeys),
12+
matchesKeyPattern(key, ignoredKeys),
1313
);
1414
return result;
1515
},
1616
push: async (locale, data, originalInput, originalLocale, pullInput) => {
1717
const ignoredSubObject = _.pickBy(pullInput, (value, key) =>
18-
_isIgnoredKey(key, ignoredKeys),
18+
matchesKeyPattern(key, ignoredKeys),
1919
);
2020
const result = _.merge({}, data, ignoredSubObject);
2121
return result;
2222
},
2323
});
2424
}
25-
26-
function _isIgnoredKey(key: string, ignoredKeys: string[]) {
27-
return ignoredKeys.some(
28-
(ignoredKey) => key.startsWith(ignoredKey) || minimatch(key, ignoredKey),
29-
);
30-
}
Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,24 @@
11
import { ILoader } from "./_types";
22
import { createLoader } from "./_utils";
33
import _ from "lodash";
4-
import { minimatch } from "minimatch";
4+
import { matchesKeyPattern } from "../utils/key-matching";
55

66
export default function createLockedKeysLoader(
77
lockedKeys: string[],
88
): ILoader<Record<string, any>, Record<string, any>> {
99
return createLoader({
1010
pull: async (locale, data) => {
11-
return _.pickBy(data, (value, key) => !_isLockedKey(key, lockedKeys));
11+
return _.pickBy(
12+
data,
13+
(value, key) => !matchesKeyPattern(key, lockedKeys),
14+
);
1215
},
1316
push: async (locale, data, originalInput) => {
1417
const lockedSubObject = _.chain(originalInput)
15-
.pickBy((value, key) => _isLockedKey(key, lockedKeys))
18+
.pickBy((value, key) => matchesKeyPattern(key, lockedKeys))
1619
.value();
1720

1821
return _.merge({}, data, lockedSubObject);
1922
},
2023
});
2124
}
22-
23-
function _isLockedKey(key: string, lockedKeys: string[]) {
24-
return lockedKeys.some(
25-
(lockedKey) => key.startsWith(lockedKey) || minimatch(key, lockedKey),
26-
);
27-
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { describe, it, expect } from "vitest";
2+
import {
3+
matchesKeyPattern,
4+
filterEntriesByPattern,
5+
formatDisplayValue,
6+
} from "./key-matching";
7+
8+
describe("matchesKeyPattern", () => {
9+
it("should match keys with prefix matching", () => {
10+
const patterns = ["api", "settings"];
11+
12+
expect(matchesKeyPattern("api/users", patterns)).toBe(true);
13+
expect(matchesKeyPattern("api/posts", patterns)).toBe(true);
14+
expect(matchesKeyPattern("settings/theme", patterns)).toBe(true);
15+
expect(matchesKeyPattern("other/key", patterns)).toBe(false);
16+
});
17+
18+
it("should match keys with glob patterns", () => {
19+
const patterns = ["api/*/users", "settings/*"];
20+
21+
expect(matchesKeyPattern("api/v1/users", patterns)).toBe(true);
22+
expect(matchesKeyPattern("api/v2/users", patterns)).toBe(true);
23+
expect(matchesKeyPattern("settings/theme", patterns)).toBe(true);
24+
expect(matchesKeyPattern("settings/notifications", patterns)).toBe(true);
25+
expect(matchesKeyPattern("api/users", patterns)).toBe(false);
26+
});
27+
28+
it("should return false for empty patterns", () => {
29+
expect(matchesKeyPattern("any/key", [])).toBe(false);
30+
});
31+
32+
it("should handle complex glob patterns", () => {
33+
const patterns = ["steps/*/type", "learningGoals/*/goal"];
34+
35+
expect(matchesKeyPattern("steps/0/type", patterns)).toBe(true);
36+
expect(matchesKeyPattern("steps/1/type", patterns)).toBe(true);
37+
expect(matchesKeyPattern("learningGoals/0/goal", patterns)).toBe(true);
38+
expect(matchesKeyPattern("steps/0/name", patterns)).toBe(false);
39+
});
40+
});
41+
42+
describe("filterEntriesByPattern", () => {
43+
it("should filter entries that match patterns", () => {
44+
const entries: [string, any][] = [
45+
["api/users", "Users API"],
46+
["api/posts", "Posts API"],
47+
["settings/theme", "Dark"],
48+
["other/key", "Value"],
49+
];
50+
const patterns = ["api", "settings"];
51+
52+
const result = filterEntriesByPattern(entries, patterns);
53+
54+
expect(result).toHaveLength(3);
55+
expect(result).toEqual([
56+
["api/users", "Users API"],
57+
["api/posts", "Posts API"],
58+
["settings/theme", "Dark"],
59+
]);
60+
});
61+
62+
it("should return empty array when no matches", () => {
63+
const entries: [string, any][] = [
64+
["key1", "value1"],
65+
["key2", "value2"],
66+
];
67+
const patterns = ["nonexistent"];
68+
69+
const result = filterEntriesByPattern(entries, patterns);
70+
71+
expect(result).toHaveLength(0);
72+
});
73+
});
74+
75+
describe("formatDisplayValue", () => {
76+
it("should return short strings as-is", () => {
77+
expect(formatDisplayValue("Hello")).toBe("Hello");
78+
expect(formatDisplayValue("Short text")).toBe("Short text");
79+
});
80+
81+
it("should truncate long strings", () => {
82+
const longString = "a".repeat(100);
83+
const result = formatDisplayValue(longString);
84+
85+
expect(result).toHaveLength(53); // 50 chars + "..."
86+
expect(result.endsWith("...")).toBe(true);
87+
});
88+
89+
it("should use custom max length", () => {
90+
const text = "Hello, World!";
91+
const result = formatDisplayValue(text, 5);
92+
93+
expect(result).toBe("Hello...");
94+
});
95+
96+
it("should stringify non-string values", () => {
97+
expect(formatDisplayValue(42)).toBe("42");
98+
expect(formatDisplayValue(true)).toBe("true");
99+
expect(formatDisplayValue({ key: "value" })).toBe('{"key":"value"}');
100+
expect(formatDisplayValue(null)).toBe("null");
101+
});
102+
103+
it("should handle arrays", () => {
104+
expect(formatDisplayValue([1, 2, 3])).toBe("[1,2,3]");
105+
});
106+
});

0 commit comments

Comments
 (0)