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:
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user