Skip to content

Commit fba8d36

Browse files
authored
feat: filesTemplate option to simplify the push command (#161)
1 parent 9635e5f commit fba8d36

26 files changed

+489
-116
lines changed

eslint.config.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@ export default tseslint.config({
1212
'scripts/**/*.{js,ts,mjs,cjs,tsx,jsx}',
1313
'*.config.{js,ts}',
1414
],
15-
ignores: ['**/*.generated.ts', 'dist/**/*', 'dist-types/**/*'],
15+
ignores: [
16+
'**/*.generated.ts',
17+
'dist/**/*',
18+
'dist-types/**/*',
19+
'src/schema.d.ts',
20+
],
1621
extends: [
1722
eslint.configs.recommended,
1823
...tseslint.configs.recommended,

package-lock.json

Lines changed: 11 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
"json5": "^2.2.3",
3838
"jsonschema": "^1.4.1",
3939
"openapi-fetch": "0.13.1",
40-
"tinyglobby": "^0.2.10",
40+
"tinyglobby": "^0.2.12",
4141
"unescape-js": "^1.1.4",
4242
"vscode-oniguruma": "^2.0.1",
4343
"vscode-textmate": "^9.1.0",

schema.json

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,20 @@
4242
"push": {
4343
"type": "object",
4444
"properties": {
45+
"filesTemplate": {
46+
"description": "A template that describes the structure of the local files and their location with file [structure template format](https://docs.tolgee.io/tolgee-cli/push-pull-strings#file-structure-template-format).\n\nExample: `./public/{namespace}/{languageTag}.json`",
47+
"anyOf": [
48+
{
49+
"type": "string"
50+
},
51+
{
52+
"type": "array",
53+
"items": { "type": "string" }
54+
}
55+
]
56+
},
4557
"files": {
46-
"description": "Define, which files should be pushed and attach language/namespace to them. By default Tolgee pushes all files specified here, you can filter them by languages and namespaces properties.",
58+
"description": "More explicit alternative to `filesTemplate`. Define, which files should be pushed and attach language/namespace to them. By default Tolgee pushes all files specified here, you can filter them by languages and namespaces properties.",
4759
"type": "array",
4860
"items": { "$ref": "#/$defs/fileMatch" }
4961
},
@@ -128,7 +140,7 @@
128140
"type": "boolean"
129141
},
130142
"fileStructureTemplate": {
131-
"description": "Defines exported file structure: https://tolgee.io/tolgee-cli/push-pull-strings#file-structure-template-format",
143+
"description": "Defines exported file structure: https://docs.tolgee.io/tolgee-cli/push-pull-strings#file-structure-template-format",
132144
"type": "string"
133145
},
134146
"emptyDir": {

src/commands/push.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import { mapImportFormat } from '../utils/mapImportFormat.js';
1919
import { TolgeeClient, handleLoadableError } from '../client/TolgeeClient.js';
2020
import { BodyOf } from '../client/internal/schema.utils.js';
2121
import { components } from '../client/internal/schema.generated.js';
22+
import { findFilesByTemplate } from '../utils/filesTemplate.js';
23+
import { valueToArray } from '../utils/valueToArray.js';
2224

2325
type ImportRequest = BodyOf<
2426
'/v2/projects/{projectId}/single-step-import',
@@ -46,6 +48,7 @@ type PushOptions = BaseOptions & {
4648
namespaces?: string[];
4749
tagNewKeys?: string[];
4850
removeOtherKeys?: boolean;
51+
filesTemplate?: string[];
4952
};
5053

5154
async function allInPattern(pattern: string) {
@@ -147,11 +150,25 @@ const pushHandler = (config: Schema) =>
147150
async function (this: Command) {
148151
const opts: PushOptions = this.optsWithGlobals();
149152

150-
if (!config.push?.files) {
151-
exitWithError('Missing option `push.files` in configuration file.');
153+
let allMatchers: FileMatch[] = [];
154+
155+
const filesTemplate = opts.filesTemplate;
156+
157+
if (!filesTemplate && !config.push?.files?.length) {
158+
exitWithError('Missing option `push.filesTemplate` or `push.files`.');
159+
}
160+
161+
if (filesTemplate) {
162+
for (const template of filesTemplate) {
163+
allMatchers = allMatchers.concat(
164+
...(await findFilesByTemplate(template))
165+
);
166+
}
152167
}
153168

154-
const filteredMatchers = config.push.files.filter((r) => {
169+
allMatchers = allMatchers.concat(...(config.push?.files || []));
170+
171+
const filteredMatchers = allMatchers.filter((r) => {
155172
if (
156173
r.language &&
157174
opts.languages &&
@@ -226,6 +243,12 @@ export default (config: Schema) =>
226243
new Command()
227244
.name('push')
228245
.description('Pushes translations to Tolgee')
246+
.addOption(
247+
new Option(
248+
'-ft, --files-template <templates...>',
249+
'A template that describes the structure of the local files and their location with file structure template format (more at: https://docs.tolgee.io/tolgee-cli/push-pull-strings#file-structure-template-format).\n\nExample: `./public/{namespace}/{languageTag}.json`\n\n'
250+
).default(valueToArray(config.push?.filesTemplate))
251+
)
229252
.addOption(
230253
new Option(
231254
'-f, --force-mode <mode>',

src/config/tolgeerc.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@ import { CosmiconfigResult } from 'cosmiconfig/dist/types.js';
88
import { error, exitWithError } from '../utils/logger.js';
99
import { existsSync } from 'fs';
1010
import { Schema } from '../schema.js';
11+
import { valueToArray } from '../utils/valueToArray.js';
1112

1213
const explorer = cosmiconfig('tolgee', {
1314
loaders: {
1415
noExt: defaultLoaders['.json'],
1516
},
1617
});
1718

18-
function parseConfig(input: Schema, configDir: string): Schema {
19+
function parseConfig(input: Schema, configDir: string) {
1920
const rc = { ...input };
2021

2122
if (rc.apiUrl !== undefined) {
@@ -60,6 +61,13 @@ function parseConfig(input: Schema, configDir: string): Schema {
6061
}));
6162
}
6263

64+
// convert relative paths in config to absolute
65+
if (rc.push?.filesTemplate) {
66+
rc.push.filesTemplate = valueToArray(rc.push.filesTemplate)?.map(
67+
(template) => resolve(configDir, template).replace(/\\/g, '/')
68+
);
69+
}
70+
6371
// convert relative paths in config to absolute
6472
if (rc.pull?.path !== undefined) {
6573
rc.pull.path = resolve(configDir, rc.pull.path).replace(/\\/g, '/');

src/schema.d.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,15 @@ export interface Schema {
103103
parser?: "react" | "vue" | "svelte" | "ngx";
104104
push?: {
105105
/**
106-
* Define, which files should be pushed and attach language/namespace to them. By default Tolgee pushes all files specified here, you can filter them by languages and namespaces properties.
106+
* A template that describes the structure of the local files and their location with file [structure template format](https://docs.tolgee.io/tolgee-cli/push-pull-strings#file-structure-template-format).
107+
*
108+
* Example: `./public/{namespace}/{languageTag}.json`
109+
*/
110+
filesTemplate?: string | string[];
111+
/**
112+
* A template that describes the structure of the local files and their location with [file structure template format](http://localhost:3001/tolgee-cli/push-pull-strings#file-structure-template-format).
113+
*
114+
* Example: `./public/{namespace}/{languageTag}.json`
107115
*/
108116
files?: FileMatch[];
109117
/**
@@ -162,7 +170,7 @@ export interface Schema {
162170
*/
163171
supportArrays?: boolean;
164172
/**
165-
* Defines exported file structure: https://tolgee.io/tolgee-cli/push-pull-strings#file-structure-template-format
173+
* Defines exported file structure: https://docs.tolgee.io/tolgee-cli/push-pull-strings#file-structure-template-format
166174
*/
167175
fileStructureTemplate?: string;
168176
/**

src/utils/filesTemplate.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { glob } from 'tinyglobby';
2+
import { exitWithError } from './logger.js';
3+
import { FileMatch } from '../schema.js';
4+
5+
const GLOB_EXISTING_DOUBLE_STAR = /(\*\*)/g;
6+
const GLOB_EXISTING_STAR = /(\*)/g;
7+
const GLOB_EXISTING_ENUM = /\{([^}]*?,[^}]*?)\}/g;
8+
9+
const PLACEHOLDER_DOUBLE_ASTERISK = '__double_asterisk';
10+
const PLACEHOLDER_ASTERISK = '__asterisk';
11+
const PLACEHOLDER_ENUM_PREFIX = '__enum:';
12+
13+
export class FileMatcherException extends Error {
14+
constructor(message: string) {
15+
super(message);
16+
this.name = this.constructor.name;
17+
}
18+
}
19+
20+
function splitToParts(template: string) {
21+
return template.split(/(\{.*?\})/g).filter(Boolean);
22+
}
23+
24+
function getVariableName(part: string) {
25+
if (part.startsWith('{') && part.endsWith('}')) {
26+
return part.substring(1, part.length - 1).trim();
27+
}
28+
return false;
29+
}
30+
31+
export function sanitizeTemplate(template: string) {
32+
let value = template;
33+
const matchedEnums = [...(value.match(GLOB_EXISTING_ENUM)?.values() || [])];
34+
matchedEnums.forEach((val) => {
35+
value = value.replace(
36+
val,
37+
`{${PLACEHOLDER_ENUM_PREFIX}${getVariableName(val)}}`
38+
);
39+
});
40+
value = value.replaceAll(
41+
GLOB_EXISTING_DOUBLE_STAR,
42+
'{' + PLACEHOLDER_DOUBLE_ASTERISK + '}'
43+
);
44+
value = value.replaceAll(
45+
GLOB_EXISTING_STAR,
46+
'{' + PLACEHOLDER_ASTERISK + '}'
47+
);
48+
return value;
49+
}
50+
51+
export function getFileMatcher(file: string, template: string) {
52+
let fileName = file;
53+
const allVariables: Record<string, string> = {};
54+
const templateParts = splitToParts(template);
55+
for (const [i, part] of templateParts.entries()) {
56+
const variable = getVariableName(part);
57+
if (!variable) {
58+
if (fileName.startsWith(part)) {
59+
fileName = fileName.substring(part.length);
60+
} else {
61+
throw new FileMatcherException(`Unexpected part "${part}"`);
62+
}
63+
} else {
64+
const next = templateParts[i + 1];
65+
if (next) {
66+
const variableEnd = fileName.indexOf(next);
67+
if (getVariableName(next) || variableEnd === -1) {
68+
throw new FileMatcherException(
69+
`Can't have two variables without separator (${part} + ${next})`
70+
);
71+
} else {
72+
allVariables[variable] = fileName.substring(0, variableEnd);
73+
fileName = fileName.substring(variableEnd);
74+
}
75+
} else {
76+
allVariables[variable] = fileName;
77+
}
78+
}
79+
}
80+
81+
const result: FileMatch = { path: file };
82+
for (const [variable, value] of Object.entries(allVariables)) {
83+
if (variable === 'languageTag') {
84+
result.language = value;
85+
} else if (variable === 'snakeLanguageTag') {
86+
result.language = value.replaceAll('_', '-');
87+
} else if (variable === 'androidLanguageTag') {
88+
if (value[3] === 'r') {
89+
result.language =
90+
value.substring(0, 3) + value.substring(4, value.length);
91+
} else {
92+
result.language = value;
93+
}
94+
} else if (variable === 'namespace') {
95+
result.namespace = value;
96+
} else if (
97+
variable !== 'extension' &&
98+
![PLACEHOLDER_ASTERISK, PLACEHOLDER_DOUBLE_ASTERISK].includes(variable) &&
99+
!variable.startsWith(PLACEHOLDER_ENUM_PREFIX)
100+
) {
101+
throw new FileMatcherException(`Unknown variable "${variable}"`);
102+
}
103+
}
104+
return result;
105+
}
106+
107+
export function getGlobPattern(template: string) {
108+
let value = template.replaceAll(
109+
GLOB_EXISTING_DOUBLE_STAR,
110+
'{__double_asterisk}'
111+
);
112+
value = value.replaceAll(GLOB_EXISTING_STAR, '{__asterisk}');
113+
const parts = splitToParts(value);
114+
const globPattern = parts
115+
.map((part) => {
116+
const variableName = getVariableName(part);
117+
if (variableName) {
118+
if (variableName === PLACEHOLDER_DOUBLE_ASTERISK) {
119+
return '**';
120+
} else if (variableName.startsWith(PLACEHOLDER_ENUM_PREFIX)) {
121+
return (
122+
'{' + variableName.substring(PLACEHOLDER_ENUM_PREFIX.length) + '}'
123+
);
124+
} else {
125+
return '*';
126+
}
127+
} else {
128+
return part;
129+
}
130+
})
131+
.join('');
132+
133+
return globPattern;
134+
}
135+
136+
export async function findFilesByTemplate(
137+
template: string
138+
): Promise<FileMatch[]> {
139+
try {
140+
const sanitized = sanitizeTemplate(template);
141+
const globPattern = getGlobPattern(sanitized);
142+
const files = await glob(globPattern, { onlyFiles: true, absolute: true });
143+
return files.map((file) => {
144+
return getFileMatcher(file, sanitized);
145+
});
146+
} catch (e) {
147+
if (e instanceof FileMatcherException) {
148+
exitWithError(e.message + ` in template ${template}`);
149+
} else {
150+
throw e;
151+
}
152+
}
153+
}

src/utils/valueToArray.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export function valueToArray<T>(
2+
value: T[] | T | undefined | null
3+
): T[] | undefined | null {
4+
if (Array.isArray(value) || value === undefined || value == null) {
5+
return value as any;
6+
} else {
7+
return [value];
8+
}
9+
}

test/__fixtures__/differentFormatsProject/android-xml.json

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,6 @@
22
"$schema": "../../../schema.json",
33
"format": "ANDROID_XML",
44
"push": {
5-
"files": [
6-
{
7-
"path": "./android-xml/values-en/**",
8-
"language": "en"
9-
}
10-
]
5+
"filesTemplate": "./android-xml/values-{languageTag}/**"
116
}
127
}

0 commit comments

Comments
 (0)