cli: add configurable --pattern option for translation message files

Adds a --pattern option to both translations export and import commands
that controls the file path layout of message files within the messages/
directory. The default pattern is {id}.{lang}.json (unchanged behavior),
but users can pass e.g. --pattern '{lang}/{id}.json' for language-based
directory grouping. The pattern is stored in the manifest so that import
picks it up automatically.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Patrik Oldsberg
2026-02-18 00:31:18 +01:00
parent d404ab30ca
commit 356cff278f
5 changed files with 327 additions and 50 deletions
@@ -16,7 +16,7 @@
import { paths } from '../../../lib/paths';
import fs from 'fs-extra';
import { resolve as resolvePath } from 'node:path';
import { dirname, resolve as resolvePath } from 'node:path';
import {
discoverFrontendPackages,
readTargetPackage,
@@ -26,9 +26,11 @@ import {
extractTranslationRefsFromSourceFile,
TranslationRefInfo,
} from '../lib/extractTranslations';
import { DEFAULT_LANGUAGE, formatMessagePath } from '../lib/messageFilePath';
interface ExportOptions {
output: string;
pattern: string;
}
export default async (options: ExportOptions) => {
@@ -37,7 +39,7 @@ export default async (options: ExportOptions) => {
paths.targetRoot,
);
const outputDir = resolvePath(paths.targetDir, options.output, 'messages');
const messagesDir = resolvePath(paths.targetDir, options.output, 'messages');
const manifestPath = resolvePath(
paths.targetDir,
options.output,
@@ -95,11 +97,15 @@ export default async (options: ExportOptions) => {
console.log(` ${ref.id} (${ref.packageName}, ${messageCount} messages)`);
}
// Write message files
await fs.ensureDir(outputDir);
// Write message files using the configured pattern
for (const ref of allRefs) {
const filePath = resolvePath(outputDir, `${ref.id}.en.json`);
const relPath = formatMessagePath(
options.pattern,
ref.id,
DEFAULT_LANGUAGE,
);
const filePath = resolvePath(messagesDir, relPath);
await fs.ensureDir(dirname(filePath));
await fs.writeJson(filePath, ref.messages, { spaces: 2 });
}
@@ -112,11 +118,20 @@ export default async (options: ExportOptions) => {
exportName: ref.exportName,
};
}
await fs.writeJson(manifestPath, { refs: manifest }, { spaces: 2 });
await fs.writeJson(
manifestPath,
{ pattern: options.pattern, refs: manifest },
{ spaces: 2 },
);
const examplePath = formatMessagePath(
options.pattern,
'<ref-id>',
DEFAULT_LANGUAGE,
);
console.log(
`\nExported ${allRefs.length} translation ref(s) to ${options.output}/`,
);
console.log(` Messages: ${options.output}/messages/<ref-id>.en.json`);
console.log(` Messages: ${options.output}/messages/${examplePath}`);
console.log(` Manifest: ${options.output}/manifest.json`);
};
@@ -16,12 +16,22 @@
import { paths } from '../../../lib/paths';
import fs from 'fs-extra';
import { resolve as resolvePath, relative as relativePath } from 'node:path';
import {
resolve as resolvePath,
relative as relativePath,
posix as posixPath,
} from 'node:path';
import { readTargetPackage } from '../lib/discoverPackages';
import {
DEFAULT_LANGUAGE,
createMessagePathParser,
formatMessagePath,
} from '../lib/messageFilePath';
interface ImportOptions {
input: string;
output: string;
pattern: string;
}
interface ManifestRefEntry {
@@ -31,6 +41,7 @@ interface ManifestRefEntry {
}
interface Manifest {
pattern?: string;
refs: Record<string, ManifestRefEntry>;
}
@@ -58,45 +69,56 @@ export default async (options: ImportOptions) => {
const manifest: Manifest = await fs.readJson(manifestPath);
// Find all translated (non-English) message files
const files = await fs.readdir(messagesDir);
const translatedFiles = files.filter(
f => f.endsWith('.json') && !f.endsWith('.en.json'),
);
// Use the pattern from manifest if available, falling back to the CLI default
const pattern = manifest.pattern ?? options.pattern;
if (translatedFiles.length === 0) {
console.log('No translated message files found.');
console.log(
'Add translated files as <ref-id>.<language>.json in the messages/ directory.',
);
return;
}
const parsePath = createMessagePathParser(pattern);
// Group translations by ref ID
const translationsByRef = new Map<string, string[]>();
for (const file of translatedFiles) {
// Expected format: <ref-id>.<language>.json
const match = file.match(/^(.+)\.([a-z]{2}(?:-[A-Z]{2})?)\.json$/);
if (!match) {
console.warn(` Warning: skipping file with unexpected name: ${file}`);
// Discover all JSON files under the messages directory
const allFiles = await collectJsonFiles(messagesDir);
// Parse each file to extract id + lang, filtering out default language files
const translationsByRef = new Map<
string,
Array<{ lang: string; relPath: string }>
>();
let skipped = 0;
for (const relPath of allFiles) {
const parsed = parsePath(relPath);
if (!parsed) {
skipped++;
continue;
}
const [, refId, language] = match;
if (!manifest.refs[refId]) {
if (parsed.lang === DEFAULT_LANGUAGE) {
continue;
}
if (!manifest.refs[parsed.id]) {
console.warn(
` Warning: skipping ${file} - ref '${refId}' not found in manifest`,
` Warning: skipping ${relPath} - ref '${parsed.id}' not found in manifest`,
);
continue;
}
const existing = translationsByRef.get(refId) ?? [];
existing.push(language);
translationsByRef.set(refId, existing);
const existing = translationsByRef.get(parsed.id) ?? [];
existing.push({ lang: parsed.lang, relPath });
translationsByRef.set(parsed.id, existing);
}
if (skipped > 0) {
console.warn(
` Warning: ${skipped} file(s) did not match the pattern '${pattern}'`,
);
}
if (translationsByRef.size === 0) {
console.log('No valid translation files found for known refs.');
console.log('No translated message files found.');
const example = formatMessagePath(pattern, '<ref-id>', 'sv');
console.log(
`Add translated files as messages/${example} in the translations directory.`,
);
return;
}
@@ -108,7 +130,7 @@ export default async (options: ImportOptions) => {
"import { createTranslationResource } from '@backstage/frontend-plugin-api';",
);
for (const [refId, languages] of [...translationsByRef.entries()].sort(
for (const [refId, entries] of [...translationsByRef.entries()].sort(
([a], [b]) => a.localeCompare(b),
)) {
const refEntry = manifest.refs[refId];
@@ -119,19 +141,17 @@ export default async (options: ImportOptions) => {
importLines.push(`import { ${refEntry.exportName} } from '${importPath}';`);
const messagesRelPath = relativePath(
resolvePath(outputPath, '..'),
messagesDir,
);
const translationEntries = languages
.sort()
.map(
lang =>
` ${JSON.stringify(
lang,
)}: () => import('./${messagesRelPath}/${refId}.${lang}.json'),`,
)
const translationEntries = entries
.sort((a, b) => a.lang.localeCompare(b.lang))
.map(({ lang, relPath }) => {
const jsonRelPath = posixPath.normalize(
relativePath(
resolvePath(outputPath, '..'),
resolvePath(messagesDir, relPath),
),
);
return ` ${JSON.stringify(lang)}: () => import('./${jsonRelPath}'),`;
})
.join('\n');
resourceLines.push(
@@ -161,11 +181,37 @@ export default async (options: ImportOptions) => {
await fs.ensureDir(resolvePath(outputPath, '..'));
await fs.writeFile(outputPath, fileContent, 'utf8');
const totalFiles = [...translationsByRef.values()].reduce(
(sum, e) => sum + e.length,
0,
);
console.log(`Generated translation resources at ${options.output}`);
console.log(
` ${translationsByRef.size} ref(s), ${translatedFiles.length} translation file(s)`,
` ${translationsByRef.size} ref(s), ${totalFiles} translation file(s)`,
);
console.log(
'\nImport this file in your app and pass the resources to your translation API setup.',
);
};
/**
* Recursively collects all .json files under a directory, returning paths
* relative to that directory using forward slashes.
*/
async function collectJsonFiles(dir: string, prefix = ''): Promise<string[]> {
const entries = await fs.readdir(dir, { withFileTypes: true });
const results: string[] = [];
for (const entry of entries) {
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
results.push(
...(await collectJsonFiles(resolvePath(dir, entry.name), relPath)),
);
} else if (entry.isFile() && entry.name.endsWith('.json')) {
results.push(relPath);
}
}
return results;
}
@@ -16,6 +16,7 @@
import yargs from 'yargs';
import { createCliPlugin } from '../../wiring/factory';
import { lazy } from '../../lib/lazy';
import { DEFAULT_MESSAGE_PATTERN } from './lib/messageFilePath';
export default createCliPlugin({
pluginId: 'translations',
@@ -33,6 +34,12 @@ export default createCliPlugin({
description:
'Output directory for exported messages and manifest',
},
pattern: {
type: 'string',
default: DEFAULT_MESSAGE_PATTERN,
description:
'File path pattern for message files, with {id} and {lang} placeholders',
},
})
.help()
.parse(args);
@@ -58,6 +65,12 @@ export default createCliPlugin({
default: 'src/translations/resources.ts',
description: 'Output path for the generated wiring module',
},
pattern: {
type: 'string',
default: DEFAULT_MESSAGE_PATTERN,
description:
'File path pattern for message files, with {id} and {lang} placeholders',
},
})
.help()
.parse(args);
@@ -0,0 +1,120 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
formatMessagePath,
createMessagePathParser,
messagePatternToGlob,
patternHasSubdirectories,
DEFAULT_MESSAGE_PATTERN,
} from './messageFilePath';
describe('messageFilePath', () => {
describe('formatMessagePath', () => {
it('formats the default pattern', () => {
expect(formatMessagePath(DEFAULT_MESSAGE_PATTERN, 'org', 'en')).toBe(
'org.en.json',
);
});
it('formats with a different language', () => {
expect(formatMessagePath(DEFAULT_MESSAGE_PATTERN, 'catalog', 'sv')).toBe(
'catalog.sv.json',
);
});
it('formats a language-directory pattern', () => {
expect(formatMessagePath('{lang}/{id}.json', 'org', 'sv')).toBe(
'sv/org.json',
);
});
it('formats a pattern with lang first in filename', () => {
expect(formatMessagePath('{lang}.{id}.json', 'org', 'de')).toBe(
'de.org.json',
);
});
});
describe('createMessagePathParser', () => {
it('parses the default pattern', () => {
const parse = createMessagePathParser(DEFAULT_MESSAGE_PATTERN);
expect(parse('org.en.json')).toEqual({ id: 'org', lang: 'en' });
});
it('parses dotted ref IDs in the default pattern', () => {
const parse = createMessagePathParser(DEFAULT_MESSAGE_PATTERN);
expect(parse('plugin.notifications.sv.json')).toEqual({
id: 'plugin.notifications',
lang: 'sv',
});
});
it('parses a language-directory pattern', () => {
const parse = createMessagePathParser('{lang}/{id}.json');
expect(parse('sv/org.json')).toEqual({ id: 'org', lang: 'sv' });
});
it('returns undefined for non-matching paths', () => {
const parse = createMessagePathParser(DEFAULT_MESSAGE_PATTERN);
expect(parse('not-a-match.txt')).toBeUndefined();
expect(parse('dir/org.en.json')).toBeUndefined();
});
it('returns undefined for invalid language code', () => {
const parse = createMessagePathParser('{lang}/{id}.json');
expect(parse('123/org.json')).toBeUndefined();
});
it('throws on pattern missing {id}', () => {
expect(() => createMessagePathParser('{lang}.json')).toThrow(
'must contain {id}',
);
});
it('throws on pattern missing {lang}', () => {
expect(() => createMessagePathParser('{id}.json')).toThrow(
'must contain {lang}',
);
});
it('throws on pattern not ending with .json', () => {
expect(() => createMessagePathParser('{id}.{lang}.yaml')).toThrow(
'must end with .json',
);
});
});
describe('messagePatternToGlob', () => {
it('converts the default pattern', () => {
expect(messagePatternToGlob(DEFAULT_MESSAGE_PATTERN)).toBe('*.*.json');
});
it('converts a language-directory pattern', () => {
expect(messagePatternToGlob('{lang}/{id}.json')).toBe('*/*.json');
});
});
describe('patternHasSubdirectories', () => {
it('returns false for flat patterns', () => {
expect(patternHasSubdirectories(DEFAULT_MESSAGE_PATTERN)).toBe(false);
});
it('returns true for patterns with directories', () => {
expect(patternHasSubdirectories('{lang}/{id}.json')).toBe(true);
});
});
});
@@ -0,0 +1,83 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// The default language for exported translation messages.
export const DEFAULT_LANGUAGE = 'en';
// Default file path pattern for translation message files.
// Supported placeholders: {id} (ref ID) and {lang} (language code).
export const DEFAULT_MESSAGE_PATTERN = '{id}.{lang}.json';
/** Formats a message file pattern into a concrete relative path. */
export function formatMessagePath(
pattern: string,
id: string,
lang: string,
): string {
return pattern.replace(/\{id\}/g, id).replace(/\{lang\}/g, lang);
}
/** Creates a parser that extracts id and lang from a relative file path. */
export function createMessagePathParser(
pattern: string,
): (relativePath: string) => { id: string; lang: string } | undefined {
validatePattern(pattern);
// Build a regex from the pattern by escaping special chars and replacing
// {id} and {lang} with named capture groups.
const escaped = pattern
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
.replace(/\\{id\\}/g, '(?<id>[^/]+)')
.replace(/\\{lang\\}/g, '(?<lang>[a-z]{2})');
const regex = new RegExp(`^${escaped}$`);
return (relPath: string) => {
const match = relPath.match(regex);
if (!match?.groups) {
return undefined;
}
return { id: match.groups.id, lang: match.groups.lang };
};
}
/** Converts a message pattern into a glob string for discovering files. */
export function messagePatternToGlob(pattern: string): string {
return pattern.replace(/\{id\}/g, '*').replace(/\{lang\}/g, '*');
}
/** Returns whether the pattern produces paths with subdirectories. */
export function patternHasSubdirectories(pattern: string): boolean {
return pattern.includes('/');
}
function validatePattern(pattern: string) {
if (!pattern.includes('{id}')) {
throw new Error(
`Invalid message file pattern: must contain {id} placeholder. Got: ${pattern}`,
);
}
if (!pattern.includes('{lang}')) {
throw new Error(
`Invalid message file pattern: must contain {lang} placeholder. Got: ${pattern}`,
);
}
if (!pattern.endsWith('.json')) {
throw new Error(
`Invalid message file pattern: must end with .json. Got: ${pattern}`,
);
}
}