Skip to content

Commit 31f6969

Browse files
committed
chore: improve TS
1 parent 3d4bdaa commit 31f6969

15 files changed

Lines changed: 174 additions & 73 deletions

CONTRIBUTING.md

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,34 @@
11
# Contributing to Loco CLI
22

3-
## Publish a new version
3+
## Development Setup
44

5-
1. Update the version in `package.json`: `npm version <major|minor|patch>`
6-
2. Run `yarn build`
7-
3. Run `npm publish`
5+
```bash
6+
pnpm install
7+
pnpm build
8+
```
9+
10+
To test the CLI locally:
11+
12+
```bash
13+
pnpm loco-cli <command>
14+
```
15+
16+
## Before Submitting a PR
17+
18+
```bash
19+
pnpm test
20+
pnpm lint
21+
pnpm format:check
22+
```
23+
24+
## Code Style
25+
26+
- 2-space indent, 100 char width, single quotes, semicolons required
27+
- Prefer typed code over `any`
28+
- No barrel exports; use explicit imports
29+
30+
## Publishing (maintainers)
31+
32+
1. `npm version <major|minor|patch>`
33+
2. `pnpm build`
34+
3. `npm publish`

src/commands/push.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { diff } from '../lib/diff';
77
import { readFiles } from '../lib/readFiles';
88
import { getGlobalOptions } from '../util/options';
99
import { printDiff } from '../util/print';
10-
import { dotObject } from '../lib/dotObject';
10+
import { flattenTranslations } from '../lib/dotObject';
1111
import { log } from '../util/logger';
1212

1313
interface CommandOptions {
@@ -94,7 +94,7 @@ const push = async ({ yes, status, tag }: CommandOptions, program: Command) => {
9494
progressbar.start(length, 0);
9595
for (const [locale, translations] of Object.entries(local)) {
9696
progressbar.increment();
97-
await apiPush(accessKey, locale, dotObject(translations), pushOptions);
97+
await apiPush(accessKey, locale, flattenTranslations(translations), pushOptions);
9898
}
9999
progressbar.stop();
100100

src/lib/api.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import fetch from 'isomorphic-unfetch';
2-
import { ProjectLocale, PullOptions, PushOptions, Translations } from '../../types';
2+
import {
3+
FlatTranslations,
4+
ProjectLocale,
5+
PullOptions,
6+
PushOptions,
7+
Translations
8+
} from '../../types';
39

410
const BASE_URL = 'https://localise.biz/api';
511

@@ -29,29 +35,28 @@ const fetchApi = async <T>(
2935
export const apiPull = async (key: string, options: PullOptions = {}) => {
3036
const translations = await fetchApi<Translations>(key, '/export/all.json', options);
3137
const locales = await fetchApi<ProjectLocale[]>(key, '/locales');
32-
if (locales?.length === 1) {
33-
return { [locales[0].code]: translations };
38+
const firstLocale = locales[0];
39+
if (locales.length === 1 && firstLocale) {
40+
return { [firstLocale.code]: translations };
3441
}
3542
return translations;
3643
};
3744

3845
export const apiPush = (
3946
key: string,
4047
locale: string,
41-
translations: Translations[string],
48+
translations: FlatTranslations,
4249
options: PushOptions = {}
4350
) =>
4451
fetchApi<void>(
4552
key,
4653
'/import/json',
4754
{
4855
locale,
49-
...Object.keys(options).reduce(
50-
(acc, key) => ({
51-
...acc,
52-
[key]: options[key as keyof PushOptions]?.toString()
53-
}),
54-
{}
56+
...Object.fromEntries(
57+
Object.entries(options)
58+
.filter(([, v]) => v !== undefined)
59+
.map(([k, v]) => [k, String(v)])
5560
)
5661
},
5762
{

src/lib/diff.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,29 @@
11
import { detailedDiff } from 'deep-object-diff';
2-
import { PushOptions, Translations } from '../../types';
2+
import { DiffRecord, PushOptions, Translations, TranslationValue } from '../../types';
33
import { dotObject } from './dotObject';
44

55
interface DetailedDiff {
6-
added: object;
7-
updated: object;
8-
deleted: object;
6+
added: TranslationValue;
7+
updated: TranslationValue;
8+
deleted: TranslationValue;
99
}
1010

11-
export const diff = (local: Translations, remote: Translations, options?: PushOptions) => {
11+
export interface DiffResult {
12+
totalCount: number;
13+
added: DiffRecord;
14+
addedCount: number;
15+
updated: DiffRecord;
16+
updatedCount: number;
17+
deleted: DiffRecord;
18+
deletedCount: number;
19+
}
20+
21+
export const diff = (
22+
local: Translations,
23+
remote: Translations,
24+
options?: PushOptions
25+
): DiffResult => {
26+
// detailedDiff returns {added, updated, deleted} matching our DetailedDiff shape
1227
const { added, updated, deleted } = detailedDiff(local, remote) as DetailedDiff;
1328
const ignoreAdded = options?.['ignore-new'];
1429
const ignoreUpdated = options?.['ignore-existing'];

src/lib/dotObject.ts

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,47 @@
1+
import { DiffRecord, TranslationValue } from '../../types';
2+
3+
type DiffValue = string | undefined;
4+
type DiffObject = { [key: string]: DiffValue | DiffObject };
5+
16
/**
27
* Transform nested JS object to key-value pairs using dot notation.
8+
* Handles undefined values from diff operations (representing deletions).
39
*/
4-
export const dotObject = (obj: object): Record<string, string> => {
5-
const res: Record<string, string> = {};
10+
export const dotObject = (obj: TranslationValue | DiffObject): DiffRecord => {
11+
const res: DiffRecord = {};
612

7-
function recurse(obj: object, keyPrefix?: string) {
8-
for (const key in obj) {
9-
const value = obj[key as keyof typeof obj];
13+
function recurse(current: TranslationValue | DiffObject, keyPrefix?: string) {
14+
for (const key of Object.keys(current)) {
15+
const value = current[key];
1016
const newKey = keyPrefix ? `${keyPrefix}.${key}` : key;
11-
if (value && typeof value === 'object') {
17+
if (value !== undefined && typeof value === 'object') {
1218
// it's a nested object, so do it again
1319
recurse(value, newKey);
1420
} else {
15-
// it's not an object, so set the property
21+
// it's a string or undefined (deletion marker)
22+
res[newKey] = value;
23+
}
24+
}
25+
}
26+
recurse(obj);
27+
return res;
28+
};
29+
30+
/**
31+
* Transform nested JS object to key-value pairs using dot notation.
32+
* Use this version when you know the input contains only strings (no diff undefined values).
33+
*/
34+
export const flattenTranslations = (obj: TranslationValue): Record<string, string> => {
35+
const res: Record<string, string> = {};
36+
37+
function recurse(current: TranslationValue, keyPrefix?: string) {
38+
for (const key of Object.keys(current)) {
39+
const value = current[key];
40+
if (value === undefined) continue;
41+
const newKey = keyPrefix ? `${keyPrefix}.${key}` : key;
42+
if (typeof value === 'object') {
43+
recurse(value, newKey);
44+
} else {
1645
res[newKey] = value;
1746
}
1847
}

src/lib/readFiles.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { join } from 'path';
44
import { Translations } from '../../types';
55
import { log } from '../util/logger';
66

7-
const readJSON = async (path: string) => {
7+
const readJSON = async (path: string): Promise<Record<string, string>> => {
88
if (!existsSync(path)) {
99
log.error(`File not found: ${path}`);
1010
process.exit(1);
@@ -15,13 +15,16 @@ const readJSON = async (path: string) => {
1515
if (err) {
1616
reject(err);
1717
}
18-
resolve(JSON.parse(data));
18+
resolve(JSON.parse(data) as Record<string, string>);
1919
});
20-
}) as Promise<Record<string, string>>;
20+
});
2121
};
2222

23-
const readFilesInDir = async (path: string, separator?: string) => {
24-
const res = {};
23+
const readFilesInDir = async (
24+
path: string,
25+
separator?: string
26+
): Promise<Record<string, string>> => {
27+
const res: Record<string, string> = {};
2528
if (!existsSync(path)) {
2629
log.error(`Directory not found: "${path}"`);
2730
process.exit(1);
@@ -32,10 +35,12 @@ const readFilesInDir = async (path: string, separator?: string) => {
3235
files.map(async file => {
3336
if (file.endsWith('.json')) {
3437
const json = await readJSON(join(path, file));
35-
Object.keys(json).forEach(key => {
36-
// @ts-expect-error Element implicitly has an any type because expression of type string can't be used to index type {}.
37-
res[`${file.replace('.json', '')}${separator}${key}`] = json[key];
38-
});
38+
for (const key of Object.keys(json)) {
39+
const value = json[key];
40+
if (value !== undefined) {
41+
res[`${file.replace('.json', '')}${separator}${key}`] = value;
42+
}
43+
}
3944
}
4045
})
4146
);

src/util/handleAsyncErrors.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import chalk from 'chalk';
22

3-
type Fun = (...args: any[]) => Promise<void>;
4-
5-
export const handleAsyncErrors = (fn: Fun) => {
6-
return (...args: any[]) =>
7-
fn(...args).catch(error => {
3+
export const handleAsyncErrors = <T extends unknown[]>(fn: (...args: T) => Promise<void>) => {
4+
return (...args: T) =>
5+
fn(...args).catch((error: Error) => {
86
if (error.message === 'HTTPError: 401 Authorization Required') {
97
console.log(
108
`\n${chalk.red(

src/util/logger.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import chalk from 'chalk';
22

33
export const log = {
44
log: console.log,
5-
info: (msg: string, ...args: any) => console.log(`💡 ${msg}`, ...args),
6-
warn: (msg: string, ...args: any) => console.warn(`⚠️ ${msg}`, ...args),
7-
error: (msg: string, ...args: any) => console.error(`${chalk.red('✗')} ${msg}`, ...args),
8-
success: (msg: string, ...args: any) => console.log(`${chalk.green('✔')} ${msg}`, ...args)
5+
info: (msg: string, ...args: unknown[]) => console.log(`💡 ${msg}`, ...args),
6+
warn: (msg: string, ...args: unknown[]) => console.warn(`⚠️ ${msg}`, ...args),
7+
error: (msg: string, ...args: unknown[]) => console.error(`${chalk.red('✗')} ${msg}`, ...args),
8+
success: (msg: string, ...args: unknown[]) => console.log(`${chalk.green('✔')} ${msg}`, ...args)
99
};

src/util/namespaces.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
1+
import { TranslationValue } from '../../types';
2+
13
export const splitIntoNamespaces = (
2-
json: object,
4+
json: TranslationValue,
35
{ defaultNs = 'default', separator = ':' } = {}
4-
) =>
5-
Object.entries(json).reduce<Record<string, object>>((acc, [key, value]) => {
6+
): Record<string, TranslationValue> =>
7+
Object.entries(json).reduce<Record<string, TranslationValue>>((acc, [key, value]) => {
68
// Pull out the group name from the key
79
const chunks = key.split(new RegExp(`${separator}(.*)`, 's'));
8-
const namespace = chunks.length > 1 ? chunks[0] : defaultNs;
9-
const assetKey = chunks.length > 1 ? chunks[1] : chunks[0];
10+
const hasNamespace = chunks.length > 1;
11+
const namespace = (hasNamespace ? chunks[0] : defaultNs) ?? defaultNs;
12+
const assetKey = (hasNamespace ? chunks[1] : chunks[0]) ?? key;
1013

1114
// Check if the group exists, if not, create it
12-
if (!acc[namespace]) {
13-
acc[namespace] = {};
14-
}
15+
const group = acc[namespace] ?? (acc[namespace] = {});
1516
// Add the current entry to the result
16-
// @ts-expect-error Element implicitly has an any type because expression of type string can't be used to index type {}.
17-
acc[namespace][assetKey] = value;
17+
group[assetKey] = value;
1818
return acc;
1919
}, {});

src/util/options.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export const getGlobalOptions = async (program: Command): Promise<Config> => {
88
log.error('Something went wrong. Sorry!');
99
process.exit(1);
1010
}
11-
const cliOptions = program.parent.opts() as Partial<Config>;
11+
const cliOptions = program.parent.opts<Partial<Config>>();
1212

1313
const fileOptions = await readConfig();
1414

@@ -31,7 +31,7 @@ export const getGlobalOptions = async (program: Command): Promise<Config> => {
3131
);
3232
}
3333
// Note: merge deep when options will be nested
34-
const mergedOptions = {
34+
const mergedOptions: Partial<Config> = {
3535
...cliOptions,
3636
...fileOptions
3737
};
@@ -41,6 +41,11 @@ export const getGlobalOptions = async (program: Command): Promise<Config> => {
4141
mergedOptions.maxFiles = parseInt(mergedOptions.maxFiles, 10);
4242
}
4343

44-
// accessKey is validated above, so this cast is safe
45-
return mergedOptions as Config;
44+
// accessKey is validated above; provide defaults for required fields
45+
return {
46+
accessKey: mergedOptions.accessKey!,
47+
localesDir: mergedOptions.localesDir ?? '.',
48+
namespaces: mergedOptions.namespaces ?? false,
49+
...mergedOptions
50+
};
4651
};

0 commit comments

Comments
 (0)