diff --git a/packages/cli/src/modules/translations/commands/export.ts b/packages/cli/src/modules/translations/commands/export.ts index e8371a7655..ca8bd2c60b 100644 --- a/packages/cli/src/modules/translations/commands/export.ts +++ b/packages/cli/src/modules/translations/commands/export.ts @@ -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, + '', + DEFAULT_LANGUAGE, + ); console.log( `\nExported ${allRefs.length} translation ref(s) to ${options.output}/`, ); - console.log(` Messages: ${options.output}/messages/.en.json`); + console.log(` Messages: ${options.output}/messages/${examplePath}`); console.log(` Manifest: ${options.output}/manifest.json`); }; diff --git a/packages/cli/src/modules/translations/commands/import.ts b/packages/cli/src/modules/translations/commands/import.ts index e279239a38..b652eb524e 100644 --- a/packages/cli/src/modules/translations/commands/import.ts +++ b/packages/cli/src/modules/translations/commands/import.ts @@ -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; } @@ -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 ..json in the messages/ directory.', - ); - return; - } + const parsePath = createMessagePathParser(pattern); - // Group translations by ref ID - const translationsByRef = new Map(); - for (const file of translatedFiles) { - // Expected format: ..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, '', '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 { + 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; +} diff --git a/packages/cli/src/modules/translations/index.ts b/packages/cli/src/modules/translations/index.ts index 29207fa548..7a7d33a28a 100644 --- a/packages/cli/src/modules/translations/index.ts +++ b/packages/cli/src/modules/translations/index.ts @@ -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); diff --git a/packages/cli/src/modules/translations/lib/messageFilePath.test.ts b/packages/cli/src/modules/translations/lib/messageFilePath.test.ts new file mode 100644 index 0000000000..6a6f3a8873 --- /dev/null +++ b/packages/cli/src/modules/translations/lib/messageFilePath.test.ts @@ -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); + }); + }); +}); diff --git a/packages/cli/src/modules/translations/lib/messageFilePath.ts b/packages/cli/src/modules/translations/lib/messageFilePath.ts new file mode 100644 index 0000000000..5f84c1ad13 --- /dev/null +++ b/packages/cli/src/modules/translations/lib/messageFilePath.ts @@ -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, '(?[^/]+)') + .replace(/\\{lang\\}/g, '(?[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}`, + ); + } +}