diff --git a/CLAUDE.md b/CLAUDE.md index c93c190..7145dca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,7 +86,7 @@ libs/ ### Application Responsibilities -- **CLI** (`apps/cli`): Commands for init, add-collection, edit-collection, delete-collection, add-locale, remove-locale, add-resource, edit-resource, delete-resource, move, normalize, translate-locale, bundle, export, import, validate, find-similar, glossary, protected-terms, install-skill. Supports both interactive (TTY) and non-interactive (CI/CD) modes. +- **CLI** (`apps/cli`): Commands for init, add-collection, edit-collection, delete-collection, add-locale, remove-locale, add-resource, edit-resource, delete-resource, move, normalize, translate-locale, bundle, export, import, validate, find-similar, glossary, protected-terms, preferred-terminology, install-skill. Supports both interactive (TTY) and non-interactive (CI/CD) modes. - **API** (`apps/api`): REST endpoints at `/api/*`, serves static Tracker UI, uses mappers to convert between core domain models and DTOs. - **Tracker UI** (`apps/tracker`): Angular app with Material UI for browsing/managing translations, uses NgRx Signals for state management. diff --git a/README.md b/README.md index 0b4232d..98ecc5f 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ When importing translations from external tools or translators, variable and pla Experience compile-time guarantees with generated translation key tokens. This feature ensures that your application uses the correct and valid translation keys, along with type completion, adding an extra layer of confidence to your translations. [Learn more](docs/features/bundle-type-generation.md). ### CLI Support -LingoTracker provides a comprehensive CLI. Its commands add, edit, delete, and move resources, and validate them. Others find similar translations, extract help-translation glossaries, manage protected terms, and normalize metadata. The rest generate bundles and import or export in JSON and XLIFF. All commands support both interactive (TTY) and non-interactive (CI) modes. +LingoTracker provides a comprehensive CLI. Its commands add, edit, delete, and move resources, and validate them. Others find similar translations, extract help-translation glossaries, manage protected terms and preferred terminology, and normalize metadata. The rest generate bundles and import or export in JSON and XLIFF. All commands support both interactive (TTY) and non-interactive (CI) modes. ### Help Translation Glossary Translating online help or documentation? The `glossary` command extracts the UI terms mentioned in a block of help text and emits a JSON glossary of their translations across every locale — so help translators reuse the exact terminology already shipped in your app. Feed it a file, a snippet, or piped stdin. [Learn more](docs/features/glossary.md). @@ -56,6 +56,13 @@ Brand names and jargon should stay unchanged through translation. Keep your prot lingo-tracker protected-terms --add iPhone --add "Node.js" ``` +### Preferred Terminology +Retired a term? Map each discouraged term to the preferred one, with an optional reason. LingoTracker then flags the old term in base-locale text. The resource editor shows a note with a one-click fix, and the CLI, base-locale imports, and `validate` print warnings. Nothing is blocked. [Protected terms](docs/features/protected-terms.md) keep words intact through translation, but preferred terminology improves the wording of the source text. [Learn more](docs/features/preferred-terminology.md). + +```bash +lingo-tracker preferred-terminology --add "Expenditure" --preferred "Investment" +``` + ### CI/CD Validation The `validate` command acts as a quality gate for your release pipeline — it exits with a non-zero code if any resource is `new`, `stale`, or untranslated. Add it to GitHub Actions, GitLab CI, or any build system to catch translation gaps before they ship. See the [validation docs](docs/features/validate.md) for CI configuration examples. diff --git a/apps/api/src/app/config/config.controller.spec.ts b/apps/api/src/app/config/config.controller.spec.ts index 4a39523..4ec35fa 100644 --- a/apps/api/src/app/config/config.controller.spec.ts +++ b/apps/api/src/app/config/config.controller.spec.ts @@ -1,15 +1,46 @@ import { basename } from 'node:path'; import { HttpException } from '@nestjs/common'; import { Test, type TestingModule } from '@nestjs/testing'; -import { resolveProtectedTermsForConfig, setGlobalProtectedTerms } from '@simoncodes-ca/core'; +import { + loadPreferredTerminology, + PreferredTerminologyValidationError, + resolvePreferredTerminologyFilePath, + resolveProtectedTermsForConfig, + setGlobalProtectedTerms, + writePreferredTerminology, +} from '@simoncodes-ca/core'; import * as mapper from '../mappers/config.mapper'; import { ConfigController } from './config.controller'; import { ConfigService } from './config.service'; -jest.mock('@simoncodes-ca/core', () => ({ - setGlobalProtectedTerms: jest.fn(), - resolveProtectedTermsForConfig: jest.fn(), -})); +jest.mock('@simoncodes-ca/core', () => { + class PreferredTerminologyValidationError extends Error { + constructor(readonly errors: unknown[]) { + super('Invalid preferred terminology rules'); + } + } + return { + setGlobalProtectedTerms: jest.fn(), + resolveProtectedTermsForConfig: jest.fn(), + loadPreferredTerminology: jest.fn(), + resolvePreferredTerminologyFilePath: jest.fn(), + writePreferredTerminology: jest.fn(), + PreferredTerminologyValidationError, + }; +}); + +const TERMINOLOGY_PATH = '/project/.lingo-tracker-preferred-terminology.json'; + +/** Runs `fn`, expecting an HttpException, and returns it for status and body assertions. */ +function catchHttpException(fn: () => unknown): HttpException { + try { + fn(); + } catch (error: unknown) { + expect(error).toBeInstanceOf(HttpException); + return error as HttpException; + } + throw new Error('Expected an HttpException'); +} describe('ConfigController', () => { let moduleRef: TestingModule; @@ -46,6 +77,8 @@ describe('ConfigController', () => { globalFilePath: '/project/.lingo-tracker-protected-terms.json', collections: {}, }); + (loadPreferredTerminology as jest.Mock).mockReturnValue({ rules: [], filePath: TERMINOLOGY_PATH }); + (resolvePreferredTerminologyFilePath as jest.Mock).mockReturnValue(TERMINOLOGY_PATH); }); describe('getConfig', () => { @@ -60,7 +93,39 @@ describe('ConfigController', () => { const mapSpy = jest.spyOn(mapper, 'mapConfigToDto'); controller.getConfig(); - expect(mapSpy).toHaveBeenCalledWith(baseConfig, resolved, basename(process.cwd())); + expect(mapSpy).toHaveBeenCalledWith(baseConfig, resolved, basename(process.cwd()), { + rules: [], + filePath: TERMINOLOGY_PATH, + }); + }); + + it('loads preferred terminology for the served config and exposes rules and path', () => { + (loadPreferredTerminology as jest.Mock).mockReturnValue({ + rules: [{ discouraged: 'Expenditure', preferred: 'Investment', reason: 'Planning term.' }], + filePath: TERMINOLOGY_PATH, + }); + + const dto = controller.getConfig(); + + expect(loadPreferredTerminology).toHaveBeenCalledWith(baseConfig, process.cwd()); + expect(dto.preferredTerminology).toEqual([ + { discouraged: 'Expenditure', preferred: 'Investment', reason: 'Planning term.' }, + ]); + expect(dto.preferredTerminologyFilePath).toBe(TERMINOLOGY_PATH); + expect(dto.preferredTerminologyError).toBeUndefined(); + }); + + it('exposes a broken terminology file as preferredTerminologyError', () => { + (loadPreferredTerminology as jest.Mock).mockReturnValue({ + rules: [], + filePath: TERMINOLOGY_PATH, + error: 'Preferred terminology file is not valid JSON', + }); + + const dto = controller.getConfig(); + + expect(dto.preferredTerminology).toBeUndefined(); + expect(dto.preferredTerminologyError).toBe('Preferred terminology file is not valid JSON'); }); it('exposes the resolved terms and their file path on the DTO', () => { @@ -108,6 +173,137 @@ describe('ConfigController', () => { } }); + describe('preferredTerminology', () => { + const rules = [ + { discouraged: 'Expenditure', preferred: 'Investment' }, + { discouraged: 'E-mail', preferred: 'email', reason: 'House style.' }, + ]; + + it('writes the rule list to the resolved file and returns the standard message', () => { + const result = controller.updateConfig({ preferredTerminology: rules }); + + expect(resolvePreferredTerminologyFilePath).toHaveBeenCalledWith(baseConfig, process.cwd()); + expect(writePreferredTerminology).toHaveBeenCalledWith(TERMINOLOGY_PATH, rules); + expect(setGlobalProtectedTerms).not.toHaveBeenCalled(); + expect(result).toEqual({ message: 'Configuration updated successfully' }); + }); + + it('writes an empty list, clearing the file', () => { + controller.updateConfig({ preferredTerminology: [] }); + + expect(writePreferredTerminology).toHaveBeenCalledWith(TERMINOLOGY_PATH, []); + }); + + it('leaves the terminology file alone when the field is absent', () => { + controller.updateConfig({ protectedTerms: ['iPhone'] }); + + expect(writePreferredTerminology).not.toHaveBeenCalled(); + }); + + it('writes both lists when both are sent', () => { + controller.updateConfig({ protectedTerms: ['iPhone'], preferredTerminology: rules }); + + expect(writePreferredTerminology).toHaveBeenCalledWith(TERMINOLOGY_PATH, rules); + expect(setGlobalProtectedTerms).toHaveBeenCalledWith(['iPhone']); + }); + + it('rejects a non-array payload with 400', () => { + const error = catchHttpException(() => + controller.updateConfig({ preferredTerminology: { discouraged: 'a', preferred: 'b' } } as never), + ); + + expect(error.getStatus()).toBe(400); + expect(error.getResponse()).toBe('preferredTerminology must be an array of rules'); + expect(writePreferredTerminology).not.toHaveBeenCalled(); + }); + + it('answers invalid rules with 400 and per-row errors indexed by submitted row', () => { + const error = catchHttpException(() => + controller.updateConfig({ + preferredTerminology: [ + { discouraged: 'Expenditure', preferred: 'Investment' }, + { discouraged: 'expenditure', preferred: 'Spend' }, + { discouraged: 'Cost', preferred: '' }, + ], + }), + ); + + expect(error.getStatus()).toBe(400); + const body = error.getResponse() as { message: string; errors: Array<{ index: number; code: string }> }; + expect(body.message).toBe('Invalid preferred terminology rules'); + expect(body.errors.map(({ index, code }) => ({ index, code }))).toEqual([ + { index: 1, code: 'duplicate' }, + { index: 2, code: 'empty' }, + ]); + expect(writePreferredTerminology).not.toHaveBeenCalled(); + }); + + it('writes neither list when the rules are invalid, even with valid protected terms', () => { + catchHttpException(() => + controller.updateConfig({ + protectedTerms: ['iPhone'], + preferredTerminology: [{ discouraged: 'Email', preferred: 'email' }], + }), + ); + + expect(setGlobalProtectedTerms).not.toHaveBeenCalled(); + expect(writePreferredTerminology).not.toHaveBeenCalled(); + }); + + it('writes neither list when protected terms are malformed', () => { + catchHttpException(() => + controller.updateConfig({ protectedTerms: 'iPhone', preferredTerminology: rules } as never), + ); + + expect(writePreferredTerminology).not.toHaveBeenCalled(); + }); + + it('rejects rows of the wrong type as invalid-type', () => { + const error = catchHttpException(() => + controller.updateConfig({ preferredTerminology: ['Expenditure'] } as never), + ); + + const body = error.getResponse() as { errors: Array<{ index: number; field: string; code: string }> }; + expect(body.errors).toEqual([expect.objectContaining({ index: 0, field: 'rule', code: 'invalid-type' })]); + }); + + it('maps a validation error thrown by the writer to the same 400 body', () => { + const errors = [{ index: 0, field: 'preferred', code: 'chain', message: 'chain' }]; + (writePreferredTerminology as jest.Mock).mockImplementationOnce(() => { + throw new PreferredTerminologyValidationError(errors as never); + }); + + const error = catchHttpException(() => controller.updateConfig({ preferredTerminology: rules })); + + expect(error.getStatus()).toBe(400); + expect(error.getResponse()).toEqual({ message: 'Invalid preferred terminology rules', errors }); + }); + + it('answers a write failure such as a missing directory with 400', () => { + (writePreferredTerminology as jest.Mock).mockImplementationOnce(() => { + throw new Error('Cannot write preferred terminology file — directory does not exist: /nope'); + }); + + const error = catchHttpException(() => controller.updateConfig({ preferredTerminology: rules })); + + expect(error.getStatus()).toBe(400); + expect(error.getResponse()).toBe('Cannot write preferred terminology file — directory does not exist: /nope'); + }); + + it('answers a malformed file pointer in the config with 400 and writes nothing', () => { + const message = '"preferredTerminologyFile" in .lingo-tracker.json must be a string path (got number)'; + (resolvePreferredTerminologyFilePath as jest.Mock).mockImplementationOnce(() => { + throw new Error(message); + }); + + const error = catchHttpException(() => controller.updateConfig({ preferredTerminology: rules })); + + expect(error.getStatus()).toBe(400); + expect(error.getResponse()).toBe(message); + expect(writePreferredTerminology).not.toHaveBeenCalled(); + }); + }); + it('throws HttpException with status 400 when the update fails', () => { const setter = setGlobalProtectedTerms as jest.Mock; setter.mockImplementationOnce(() => { diff --git a/apps/api/src/app/config/config.controller.ts b/apps/api/src/app/config/config.controller.ts index bc8830a..c520f8d 100644 --- a/apps/api/src/app/config/config.controller.ts +++ b/apps/api/src/app/config/config.controller.ts @@ -1,10 +1,25 @@ import { basename } from 'node:path'; import { Body, Controller, Get, HttpException, HttpStatus, Put } from '@nestjs/common'; -import { resolveProtectedTermsForConfig, setGlobalProtectedTerms } from '@simoncodes-ca/core'; -import type { LingoTrackerConfigDto, UpdateConfigDto } from '@simoncodes-ca/data-transfer'; +import { + loadPreferredTerminology, + PreferredTerminologyValidationError, + resolvePreferredTerminologyFilePath, + resolveProtectedTermsForConfig, + setGlobalProtectedTerms, + writePreferredTerminology, +} from '@simoncodes-ca/core'; +import type { + LingoTrackerConfigDto, + PreferredTermRuleErrorDto, + PreferredTermRulesErrorResponseDto, + UpdateConfigDto, +} from '@simoncodes-ca/data-transfer'; +import { validatePreferredTermRules } from '@simoncodes-ca/domain'; import { mapConfigToDto, mapDtoToConfigUpdate } from '../mappers/config.mapper'; import { ConfigService } from './config.service'; +const INVALID_RULES_MESSAGE = 'Invalid preferred terminology rules'; + @Controller('config') export class ConfigController { constructor(private readonly configService: ConfigService) {} @@ -12,13 +27,24 @@ export class ConfigController { @Get() getConfig(): LingoTrackerConfigDto { const config = this.configService.getConfig(); - return mapConfigToDto(config, resolveProtectedTermsForConfig(config), basename(process.cwd())); + const cwd = process.cwd(); + return mapConfigToDto( + config, + resolveProtectedTermsForConfig(config), + basename(cwd), + loadPreferredTerminology(config, cwd), + ); } /** * Updates supported top-level config fields. Only the fields carried by * `UpdateConfigDto` are writable — `collections`, `locales`, and `baseLocale` * are never touched by this endpoint. + * + * Every submitted field is validated before anything is written, so a bad + * preferred-terminology list never lands alongside a half-applied protected-terms + * change. Invalid rules answer 400 with `{ message, errors }`, `errors` indexed by + * row of the submitted list. */ @Put() updateConfig(@Body() dto: UpdateConfigDto): { message: string } { @@ -30,18 +56,41 @@ export class ConfigController { ) { throw new HttpException('protectedTerms must be an array of strings', HttpStatus.BAD_REQUEST); } + + const preferredTerminology: unknown = dto?.preferredTerminology; + if (preferredTerminology !== undefined) { + if (!Array.isArray(preferredTerminology)) { + throw new HttpException('preferredTerminology must be an array of rules', HttpStatus.BAD_REQUEST); + } + const ruleErrors = validatePreferredTermRules(preferredTerminology); + if (ruleErrors.length > 0) { + throw invalidRulesException(ruleErrors); + } + } + const update = mapDtoToConfigUpdate(dto ?? {}); - const terms = update.protectedTerms; - if (terms !== undefined) { - setGlobalProtectedTerms(terms); + if (update.preferredTerminology !== undefined) { + const filePath = resolvePreferredTerminologyFilePath(this.configService.getConfig(), process.cwd()); + writePreferredTerminology(filePath, update.preferredTerminology); + } + if (update.protectedTerms !== undefined) { + setGlobalProtectedTerms(update.protectedTerms); } return { message: 'Configuration updated successfully' }; } catch (error: unknown) { if (error instanceof HttpException) { throw error; } + if (error instanceof PreferredTerminologyValidationError) { + throw invalidRulesException(error.errors); + } const errorMessage = error instanceof Error ? error.message : 'Error updating configuration'; throw new HttpException(errorMessage, HttpStatus.BAD_REQUEST); } } } + +function invalidRulesException(errors: PreferredTermRuleErrorDto[]): HttpException { + const body: PreferredTermRulesErrorResponseDto = { message: INVALID_RULES_MESSAGE, errors }; + return new HttpException(body, HttpStatus.BAD_REQUEST); +} diff --git a/apps/api/src/app/mappers/config.mapper.spec.ts b/apps/api/src/app/mappers/config.mapper.spec.ts index 00e4948..514ae56 100644 --- a/apps/api/src/app/mappers/config.mapper.spec.ts +++ b/apps/api/src/app/mappers/config.mapper.spec.ts @@ -86,6 +86,60 @@ describe('config.mapper', () => { expect('bundles' in dto).toBe(false); }); + it('maps loaded preferred terminology rules and file path', () => { + const dto = mapConfigToDto(config, resolved, undefined, { + rules: [ + { discouraged: 'Expenditure', preferred: 'Investment', reason: 'Planning term.' }, + { discouraged: 'E-mail', preferred: 'email' }, + ], + filePath: '/project/.lingo-tracker-preferred-terminology.json', + }); + + expect(dto.preferredTerminology).toEqual([ + { discouraged: 'Expenditure', preferred: 'Investment', reason: 'Planning term.' }, + { discouraged: 'E-mail', preferred: 'email' }, + ]); + expect(dto.preferredTerminology?.[1]).not.toHaveProperty('reason'); + expect(dto.preferredTerminologyFilePath).toBe('/project/.lingo-tracker-preferred-terminology.json'); + expect('preferredTerminologyError' in dto).toBe(false); + expect('preferredTerminologyWarning' in dto).toBe(false); + }); + + it('omits an empty rule list but keeps the file path', () => { + const dto = mapConfigToDto(config, resolved, undefined, { + rules: [], + filePath: '/project/.lingo-tracker-preferred-terminology.json', + }); + + expect('preferredTerminology' in dto).toBe(false); + expect(dto.preferredTerminologyFilePath).toBe('/project/.lingo-tracker-preferred-terminology.json'); + }); + + it('maps a load error and a missing-file warning to their own fields', () => { + const broken = mapConfigToDto(config, resolved, undefined, { + rules: [], + filePath: '/project/terms.json', + error: 'Preferred terminology file is not valid JSON', + }); + const missing = mapConfigToDto(config, resolved, undefined, { + rules: [], + filePath: '/project/terms.json', + warning: 'Preferred terminology file not found', + }); + + expect(broken.preferredTerminologyError).toBe('Preferred terminology file is not valid JSON'); + expect('preferredTerminologyWarning' in broken).toBe(false); + expect(missing.preferredTerminologyWarning).toBe('Preferred terminology file not found'); + expect('preferredTerminologyError' in missing).toBe(false); + }); + + it('omits every preferred-terminology field when nothing was loaded', () => { + const dto = mapConfigToDto(config, resolved); + + expect('preferredTerminology' in dto).toBe(false); + expect('preferredTerminologyFilePath' in dto).toBe(false); + }); + it('exposes projectName only when provided', () => { expect(mapConfigToDto(config, undefined, 'lingo-tracker').projectName).toBe('lingo-tracker'); expect('projectName' in mapConfigToDto(config)).toBe(false); @@ -97,6 +151,11 @@ describe('config.mapper', () => { expect(mapDtoToConfigUpdate({ protectedTerms: ['iPhone'] })).toEqual({ protectedTerms: ['iPhone'] }); }); + it('maps preferredTerminology', () => { + const rules = [{ discouraged: 'Expenditure', preferred: 'Investment' }]; + expect(mapDtoToConfigUpdate({ preferredTerminology: rules })).toEqual({ preferredTerminology: rules }); + }); + it('returns an empty update when no writable fields present', () => { expect(mapDtoToConfigUpdate({})).toEqual({}); }); diff --git a/apps/api/src/app/mappers/config.mapper.ts b/apps/api/src/app/mappers/config.mapper.ts index 3119866..82fadde 100644 --- a/apps/api/src/app/mappers/config.mapper.ts +++ b/apps/api/src/app/mappers/config.mapper.ts @@ -2,12 +2,14 @@ import type { BundleDefinition, LingoTrackerCollection, LingoTrackerConfig, + LoadPreferredTerminologyResult, ResolvedProtectedTerms, } from '@simoncodes-ca/core'; import type { BundleDefinitionDto, LingoTrackerCollectionDto, LingoTrackerConfigDto, + PreferredTermRuleDto, UpdateConfigDto, } from '@simoncodes-ca/data-transfer'; import { mapBundleDefinitionToDto } from './bundle.mapper'; @@ -27,14 +29,43 @@ function mapConfigBundles(bundles: Record): Record { + return { + ...(terminology.rules.length > 0 && { + preferredTerminology: terminology.rules.map( + (rule): PreferredTermRuleDto => ({ + discouraged: rule.discouraged, + preferred: rule.preferred, + ...(rule.reason !== undefined && { reason: rule.reason }), + }), + ), + }), + preferredTerminologyFilePath: terminology.filePath, + ...(terminology.error !== undefined && { preferredTerminologyError: terminology.error }), + ...(terminology.warning !== undefined && { preferredTerminologyWarning: terminology.warning }), + }; +} + +/** + * Maps config to its DTO. `resolved` carries the protected terms and `terminology` the + * preferred-terminology file, both already read from disk by the caller — the mapper + * itself stays free of file I/O. `projectName` is the served workspace folder name, + * supplied by the controller for the same reason. */ export function mapConfigToDto( config: LingoTrackerConfig, resolved?: ResolvedProtectedTerms, projectName?: string, + terminology?: LoadPreferredTerminologyResult, ): LingoTrackerConfigDto { return { exportFolder: config.exportFolder, @@ -48,6 +79,7 @@ export function mapConfigToDto( translation: config.translation, protectedTerms: resolved?.globalTerms.length ? [...resolved.globalTerms] : undefined, protectedTermsFilePath: resolved?.globalFilePath, + ...(terminology && mapPreferredTerminology(terminology)), ...(projectName && { projectName }), }; } @@ -57,12 +89,20 @@ export function mapConfigToDto( * Only supported writeable globals are mapped — `collections`, `locales`, and * `baseLocale` are intentionally never written through this path. */ -export function mapDtoToConfigUpdate( - dto: UpdateConfigDto, -): Partial & { protectedTerms?: string[] } { - const update: { protectedTerms?: string[] } = {}; +export function mapDtoToConfigUpdate(dto: UpdateConfigDto): Partial & ConfigFileUpdate { + const update: ConfigFileUpdate = {}; if (dto.protectedTerms !== undefined) { update.protectedTerms = dto.protectedTerms; } + if (dto.preferredTerminology !== undefined) { + update.preferredTerminology = dto.preferredTerminology; + } return update; } + +/** Writable lists that live in their own files rather than in `.lingo-tracker.json`. */ +export interface ConfigFileUpdate { + protectedTerms?: string[]; + /** Passed through untouched: the controller shape-checks it and the core writer validates it. */ + preferredTerminology?: PreferredTermRuleDto[]; +} diff --git a/apps/cli/src/add-resource/add-resource.test.ts b/apps/cli/src/add-resource/add-resource.test.ts index b69734d..9c278a5 100644 --- a/apps/cli/src/add-resource/add-resource.test.ts +++ b/apps/cli/src/add-resource/add-resource.test.ts @@ -1,9 +1,9 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { addResourceCommand } from './add-resource'; import * as fs from 'node:fs'; -import prompts from 'prompts'; import * as core from '@simoncodes-ca/core'; +import prompts from 'prompts'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as utils from '../utils'; +import { addResourceCommand } from './add-resource'; // Mock prompts to avoid interactive input vi.mock('prompts', () => ({ @@ -30,6 +30,7 @@ vi.mock('@simoncodes-ca/core', async () => { ...actual, CONFIG_FILENAME: '.lingo-tracker.json', addResource: vi.fn().mockResolvedValue({ resolvedKey: 'test.key', created: true }), + loadPreferredTerminology: vi.fn(() => ({ rules: [], filePath: '/test/.lingo-tracker-preferred-terminology.json' })), resolveResourceKey: vi.fn((key: string, targetFolder?: string) => { return targetFolder ? `${targetFolder}.${key}` : key; }), @@ -362,4 +363,103 @@ describe('addResourceCommand', () => { writable: true, }); }); + + describe('preferred terminology', () => { + const filePath = '/test/.lingo-tracker-preferred-terminology.json'; + const config = { + collections: { TestCollection: { translationsFolder: 'translations', baseLocale: 'en', locales: ['en', 'fr'] } }, + baseLocale: 'en', + locales: ['en', 'fr'], + }; + let originalIsTTY: boolean | undefined; + let logSpy: ReturnType; + + beforeEach(() => { + vi.mocked(utils.loadConfiguration).mockReturnValue({ + config, + configPath: '/test/.lingo-tracker.json', + cwd: '/test', + }); + vi.mocked(utils.promptForCollection).mockResolvedValue('TestCollection'); + vi.mocked(utils.resolveWritableCollection).mockReturnValue({ + name: 'TestCollection', + config: config.collections.TestCollection, + translationsFolderPath: '/test/translations', + }); + originalIsTTY = process.stdout.isTTY; + Object.defineProperty(process.stdout, 'isTTY', { value: false, writable: true }); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + }); + + afterEach(() => { + Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, writable: true }); + logSpy.mockRestore(); + }); + + const add = (value: string) => addResourceCommand({ collection: 'TestCollection', key: 'budget.title', value }); + + it('warns once per matching rule after a successful add, with the reason on its own line', async () => { + vi.mocked(core.loadPreferredTerminology).mockReturnValue({ + rules: [ + { discouraged: 'Expenditure', preferred: 'Investment', reason: 'Finance style guide' }, + { discouraged: 'e-mail', preferred: 'email' }, + ], + filePath, + }); + + await add('Expenditure and more expenditure, by e-mail'); + + expect(core.addResource).toHaveBeenCalled(); + expect(core.loadPreferredTerminology).toHaveBeenCalledWith(config, '/test'); + const lines = logSpy.mock.calls.map((call) => String(call[0])); + expect(lines).toContain('⚠️ Preferred terminology: consider "Investment" instead of "Expenditure"'); + expect(lines).toContain(' Finance style guide'); + expect(lines).toContain('⚠️ Preferred terminology: consider "email" instead of "e-mail"'); + expect(lines.filter((line) => line.includes('Preferred terminology:'))).toHaveLength(2); + expect(process.exitCode ?? 0).toBe(0); + }); + + it('prints nothing when the value uses no discouraged term', async () => { + vi.mocked(core.loadPreferredTerminology).mockReturnValue({ + rules: [{ discouraged: 'Expenditure', preferred: 'Investment' }], + filePath, + }); + + await add('Investment summary'); + + expect(logSpy.mock.calls.some((call) => String(call[0]).includes('Preferred terminology'))).toBe(false); + }); + + it('prints one config warning and skips the check when the rule file is broken', async () => { + vi.mocked(core.loadPreferredTerminology).mockReturnValue({ rules: [], filePath, error: 'not valid JSON' }); + + await add('Expenditure'); + + const lines = logSpy.mock.calls.map((call) => String(call[0])); + expect(lines).toContain('⚠️ Preferred terminology checks skipped: not valid JSON'); + expect(lines.filter((line) => line.includes('Preferred terminology'))).toHaveLength(1); + }); + + it('prints the missing-explicit-file warning', async () => { + vi.mocked(core.loadPreferredTerminology).mockReturnValue({ + rules: [], + filePath, + warning: 'Preferred terminology file not found: /test/terms.json. Treating as an empty list.', + }); + + await add('Expenditure'); + + expect(logSpy).toHaveBeenCalledWith( + '⚠️ Preferred terminology file not found: /test/terms.json. Treating as an empty list.', + ); + }); + + it('does not check when the add fails', async () => { + vi.mocked(core.addResource).mockRejectedValueOnce(new Error('boom')); + + await add('Expenditure'); + + expect(core.loadPreferredTerminology).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/cli/src/add-resource/add-resource.ts b/apps/cli/src/add-resource/add-resource.ts index b76126a..8c70578 100644 --- a/apps/cli/src/add-resource/add-resource.ts +++ b/apps/cli/src/add-resource/add-resource.ts @@ -1,16 +1,17 @@ -import { readFileSync, existsSync } from 'node:fs'; -import { resolve, join } from 'node:path'; -import prompts from 'prompts'; +import { existsSync, readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; import type { LingoTrackerConfig } from '@simoncodes-ca/core'; -import { createDefaultTranslations, addResource } from '@simoncodes-ca/core'; -import { type TranslationStatus, resolveResourceKey, splitResolvedKey } from '@simoncodes-ca/domain'; +import { addResource, createDefaultTranslations } from '@simoncodes-ca/core'; +import { resolveResourceKey, splitResolvedKey, type TranslationStatus, translocoToICU } from '@simoncodes-ca/domain'; +import prompts from 'prompts'; import { + ConsoleFormatter, + ErrorMessages, loadConfiguration, parseCommaSeparatedList, promptForCollection, resolveWritableCollection, - ConsoleFormatter, - ErrorMessages, + warnAboutPreferredTerminology, } from '../utils'; export interface AddResourceOptions { @@ -102,6 +103,10 @@ export async function addResourceCommand(options: AddResourceOptions): Promise ({ existsSync: vi.fn(), @@ -24,6 +24,7 @@ vi.mock('@simoncodes-ca/core', async () => { return { ...actual, editResource: vi.fn(), + loadPreferredTerminology: vi.fn(() => ({ rules: [], filePath: '/test/project/terms.json' })), }; }); @@ -265,4 +266,76 @@ describe('editResourceCommand', () => { }), ); }); + + describe('preferred terminology', () => { + const rules = [{ discouraged: 'Expenditure', preferred: 'Investment', reason: 'Finance style guide' }]; + + beforeEach(() => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify(mockConfig)); + vi.mocked(loadPreferredTerminology).mockReturnValue({ rules, filePath: '/test/project/terms.json' }); + }); + + const logged = (spy: ReturnType) => spy.mock.calls.map((call) => String(call[0])); + + it('warns about the new base value after a successful edit', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + mockEditResource.mockResolvedValue({ resolvedKey: 'budget.title', updated: true }); + + await editResourceCommand({ collection: 'default', key: 'budget.title', baseValue: 'Capital expenditure' }); + + expect(logged(logSpy)).toContain('⚠️ Preferred terminology: consider "Investment" instead of "Expenditure"'); + expect(logged(logSpy)).toContain(' Finance style guide'); + expect(process.exitCode ?? 0).toBe(0); + logSpy.mockRestore(); + }); + + it('does not check when the base value was not part of the edit', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + mockEditResource.mockResolvedValue({ resolvedKey: 'budget.title', updated: true }); + + await editResourceCommand({ + collection: 'default', + key: 'budget.title', + baseValue: '', + locale: 'fr', + localeValue: 'Expenditure', + }); + + expect(loadPreferredTerminology).not.toHaveBeenCalled(); + expect(logged(logSpy).some((line) => line.includes('Preferred terminology'))).toBe(false); + logSpy.mockRestore(); + }); + + it('does not check when nothing changed', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + mockEditResource.mockResolvedValue({ + resolvedKey: 'budget.title', + updated: false, + message: 'No changes detected', + }); + + await editResourceCommand({ collection: 'default', key: 'budget.title', baseValue: 'Capital expenditure' }); + + expect(loadPreferredTerminology).not.toHaveBeenCalled(); + logSpy.mockRestore(); + }); + + it('prints one config warning and skips the check when the rule file is broken', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.mocked(loadPreferredTerminology).mockReturnValue({ + rules: [], + filePath: '/test/project/terms.json', + error: 'not valid JSON', + }); + mockEditResource.mockResolvedValue({ resolvedKey: 'budget.title', updated: true }); + + await editResourceCommand({ collection: 'default', key: 'budget.title', baseValue: 'Capital expenditure' }); + + const lines = logged(logSpy); + expect(lines).toContain('⚠️ Preferred terminology checks skipped: not valid JSON'); + expect(lines.filter((line) => line.includes('Preferred terminology'))).toHaveLength(1); + logSpy.mockRestore(); + }); + }); }); diff --git a/apps/cli/src/commands/edit-resource.ts b/apps/cli/src/commands/edit-resource.ts index baf0f6c..055874e 100644 --- a/apps/cli/src/commands/edit-resource.ts +++ b/apps/cli/src/commands/edit-resource.ts @@ -1,14 +1,16 @@ import { resolve } from 'node:path'; -import type prompts from 'prompts'; import type { LingoTrackerConfig } from '@simoncodes-ca/core'; import { editResource } from '@simoncodes-ca/core'; +import { translocoToICU } from '@simoncodes-ca/domain'; +import type prompts from 'prompts'; import { + ConsoleFormatter, + executePromptsWithFallback, loadConfiguration, parseCommaSeparatedList, promptForCollection, resolveWritableCollection, - ConsoleFormatter, - executePromptsWithFallback, + warnAboutPreferredTerminology, } from '../utils'; export interface EditResourceOptions { @@ -87,6 +89,11 @@ export async function editResourceCommand(options: EditResourceOptions): Promise if (result.updated) { ConsoleFormatter.success(`Resource "${result.resolvedKey}" updated successfully.`); + // Only a base value supplied in this invocation is checked; editing a comment + // or a translation should not re-raise advice about untouched wording. + if (editOptions.baseValue !== undefined) { + warnAboutPreferredTerminology(config, cwd, translocoToICU(editOptions.baseValue)); + } } else { ConsoleFormatter.info(result.message || 'No changes detected'); } diff --git a/apps/cli/src/commands/import-cmd.spec.ts b/apps/cli/src/commands/import-cmd.spec.ts index 0c4402f..a20580a 100644 --- a/apps/cli/src/commands/import-cmd.spec.ts +++ b/apps/cli/src/commands/import-cmd.spec.ts @@ -1,7 +1,8 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { importCommand, type ImportCommandOptions } from './import-cmd'; -import * as path from 'path'; import * as fs from 'fs'; +import * as path from 'path'; +import prompts from 'prompts'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { type ImportCommandOptions, importCommand } from './import-cmd'; const fsMocks = vi.hoisted(() => ({ existsSync: vi.fn(), @@ -41,6 +42,10 @@ vi.mock('@simoncodes-ca/core', () => ({ detectImportFormat: vi.fn(), generateImportSummary: vi.fn(() => '# Import Summary\n\nTest summary'), readEffectiveProtectedTerms: vi.fn(() => []), + loadPreferredTerminology: vi.fn(() => ({ + rules: [], + filePath: '/test/project/.lingo-tracker-preferred-terminology.json', + })), })); // Mock utilities @@ -76,8 +81,14 @@ vi.mock('../utils', () => ({ })); // Import the mocked functions -import { importFromJson, importFromXliff, detectImportFormat } from '@simoncodes-ca/core'; -import { loadConfiguration, promptForCollection, resolveWritableCollection, ConsoleFormatter } from '../utils'; +import { detectImportFormat, importFromJson, importFromXliff, loadPreferredTerminology } from '@simoncodes-ca/core'; +import { + ConsoleFormatter, + isInteractiveTerminal, + loadConfiguration, + promptForCollection, + resolveWritableCollection, +} from '../utils'; describe('import-cmd', () => { const baseConfig = { @@ -485,4 +496,164 @@ describe('import-cmd', () => { expect(importFromJson).not.toHaveBeenCalled(); }); }); + + describe('Interactive locale prompt', () => { + beforeEach(() => { + vi.mocked(isInteractiveTerminal).mockReturnValue(true); + vi.mocked(prompts).mockResolvedValue({ locale: 'de' }); + vi.mocked(importFromJson).mockReturnValue({ ...baseImportResult, locale: 'de', warnings: [] } as never); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.mocked(isInteractiveTerminal).mockReturnValue(false); + }); + + /** Choices of the target-locale prompt. */ + const offeredLocales = (): unknown => { + const question = vi + .mocked(prompts) + .mock.calls.map(([asked]) => asked) + .find((asked) => !Array.isArray(asked) && asked.name === 'locale'); + return question && !Array.isArray(question) ? question.choices : undefined; + }; + + it("offers the collection's own locales, minus its base locale", async () => { + vi.mocked(promptForCollection).mockResolvedValue('docs'); + vi.mocked(resolveWritableCollection).mockReturnValue({ + name: 'docs', + config: { translationsFolder: 'src/docs-translations', baseLocale: 'fr', locales: ['fr', 'de'] }, + translationsFolderPath: '/test/project/src/docs-translations', + }); + + await importCommand({ source: '/test/import.json', format: 'json', strategy: 'translation-service' }); + + expect(offeredLocales()).toEqual([{ title: 'de', value: 'de' }]); + expect(importFromJson).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ locale: 'de' })); + }); + + it('offers the project locales for a collection without its own', async () => { + await importCommand({ source: '/test/import.json', format: 'json', strategy: 'translation-service' }); + + expect(offeredLocales()).toEqual([ + { title: 'es', value: 'es' }, + { title: 'fr', value: 'fr' }, + ]); + }); + }); + + describe('Preferred terminology', () => { + const filePath = '/test/project/.lingo-tracker-preferred-terminology.json'; + const rules = [{ discouraged: 'Expenditure', preferred: 'Investment' }]; + + it('passes the loaded rules to the import', async () => { + vi.mocked(loadPreferredTerminology).mockReturnValueOnce({ rules, filePath }); + vi.mocked(importFromJson).mockReturnValue({ ...baseImportResult, locale: 'en', warnings: [] } as never); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await importCommand({ source: '/test/import.json', locale: 'en', format: 'json', strategy: 'migration' }); + + expect(loadPreferredTerminology).toHaveBeenCalledWith(baseConfig, '/test/project'); + expect(importFromJson).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ preferredTerminology: rules }), + ); + }); + + it('adds one config warning on a base-locale import when the rule file is broken', async () => { + vi.mocked(loadPreferredTerminology).mockReturnValueOnce({ rules: [], filePath, error: 'not valid JSON' }); + vi.mocked(importFromJson).mockReturnValue({ ...baseImportResult, locale: 'en', warnings: [] } as never); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await importCommand({ source: '/test/import.json', locale: 'en', format: 'json', strategy: 'migration' }); + + expect(importFromJson).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ preferredTerminology: [] }), + ); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Warnings (1)')); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Preferred terminology checks skipped: not valid JSON'), + ); + }); + + it('says nothing about a broken rule file on a target-locale import', async () => { + vi.mocked(loadPreferredTerminology).mockReturnValueOnce({ rules: [], filePath, error: 'not valid JSON' }); + vi.mocked(importFromJson).mockReturnValue({ ...baseImportResult, locale: 'es', warnings: [] } as never); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await importCommand({ source: '/test/import.json', locale: 'es', format: 'json' }); + + expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining('Preferred terminology')); + }); + + it('passes the project base locale for a collection without its own', async () => { + vi.mocked(importFromJson).mockReturnValue({ ...baseImportResult, locale: 'es', warnings: [] } as never); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await importCommand({ source: '/test/import.json', locale: 'es', format: 'json' }); + + expect(importFromJson).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ baseLocale: 'en' })); + }); + + describe('collection with its own base locale', () => { + beforeEach(() => { + vi.mocked(promptForCollection).mockResolvedValue('docs'); + vi.mocked(resolveWritableCollection).mockReturnValue({ + name: 'docs', + config: { translationsFolder: 'src/docs-translations', baseLocale: 'fr' }, + translationsFolderPath: '/test/project/src/docs-translations', + }); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + }); + + it("treats an import into the collection's base locale as a base-locale import", async () => { + vi.mocked(loadPreferredTerminology).mockReturnValueOnce({ rules, filePath }); + vi.mocked(importFromJson).mockReturnValue({ ...baseImportResult, locale: 'fr', warnings: [] } as never); + + await importCommand({ + source: '/test/import.json', + locale: 'fr', + format: 'json', + collection: 'docs', + strategy: 'migration', + }); + + expect(importFromJson).toHaveBeenCalledWith( + '/test/project/src/docs-translations', + expect.objectContaining({ locale: 'fr', baseLocale: 'fr', preferredTerminology: rules }), + ); + }); + + it("adds the config warning on an import into the collection's base locale", async () => { + vi.mocked(loadPreferredTerminology).mockReturnValueOnce({ rules: [], filePath, error: 'not valid JSON' }); + vi.mocked(importFromJson).mockReturnValue({ ...baseImportResult, locale: 'fr', warnings: [] } as never); + + await importCommand({ + source: '/test/import.json', + locale: 'fr', + format: 'json', + collection: 'docs', + strategy: 'migration', + }); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Preferred terminology checks skipped: not valid JSON'), + ); + }); + + it('treats the project base locale as a target locale and adds no config warning', async () => { + vi.mocked(loadPreferredTerminology).mockReturnValueOnce({ rules: [], filePath, error: 'not valid JSON' }); + vi.mocked(importFromJson).mockReturnValue({ ...baseImportResult, locale: 'en', warnings: [] } as never); + + await importCommand({ source: '/test/import.json', locale: 'en', format: 'json', collection: 'docs' }); + + expect(importFromJson).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ locale: 'en', baseLocale: 'fr' }), + ); + expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining('Preferred terminology')); + }); + }); + }); }); diff --git a/apps/cli/src/commands/import-cmd.ts b/apps/cli/src/commands/import-cmd.ts index 3c17668..a94b7c3 100644 --- a/apps/cli/src/commands/import-cmd.ts +++ b/apps/cli/src/commands/import-cmd.ts @@ -1,26 +1,26 @@ -import * as path from 'path'; -import * as fs from 'fs'; -import prompts from 'prompts'; import { - type LingoTrackerConfig, - type ImportOptions, + detectImportFormat, + generateImportSummary, type ImportFormat, + type ImportOptions, + type ImportResult, type ImportStrategy, importFromJson, importFromXliff, - detectImportFormat, - type ImportResult, - generateImportSummary, + loadPreferredTerminology, readEffectiveProtectedTerms, } from '@simoncodes-ca/core'; +import * as fs from 'fs'; +import * as path from 'path'; +import prompts from 'prompts'; import { - loadConfiguration, - promptForCollection, - resolveWritableCollection, + buildSummaryPath, ConsoleFormatter, ErrorMessages, isInteractiveTerminal, - buildSummaryPath, + loadConfiguration, + promptForCollection, + resolveWritableCollection, } from '../utils'; export const LARGE_FILE_SIZE_THRESHOLD = 5; @@ -51,9 +51,13 @@ export async function importCommand(options: ImportCommandOptions): Promise; try { - answers = await promptForMissing({ ...options, collection: collectionName }, config); + answers = await promptForMissing({ ...options, collection: collectionName }, locales, baseLocale); } catch (error) { if ((error as Error).message === 'Import cancelled') { ConsoleFormatter.error(ErrorMessages.OPERATION_CANCELLED('Import')); @@ -89,11 +93,14 @@ export async function importCommand(options: ImportCommandOptions): Promise console.log(` ${msg}`) : undefined, }; @@ -153,6 +163,15 @@ export async function importCommand(options: ImportCommandOptions): Promise { const answers = { ...options }; @@ -259,10 +279,6 @@ async function promptForMissing( answers.format = formatAnswer.format; } - // Get configured locales - const configuredLocales = config.locales || []; - const baseLocale = config.baseLocale || 'en'; - // Prompt for import strategy if (!answers.strategy) { const strategyAnswer = await prompts({ diff --git a/apps/cli/src/commands/install-skill.spec.ts b/apps/cli/src/commands/install-skill.spec.ts index b456ed4..3478036 100644 --- a/apps/cli/src/commands/install-skill.spec.ts +++ b/apps/cli/src/commands/install-skill.spec.ts @@ -86,6 +86,15 @@ describe('substituteSkillTemplate (single collection)', () => { ); }); + it('contains the preferred-terminology list, add and remove commands', () => { + const output = substituteSkillTemplate(template, singleCollection); + expect(output).toContain('npx lingo-tracker preferred-terminology --list'); + expect(output).toContain( + 'npx lingo-tracker preferred-terminology --add "" --preferred ""', + ); + expect(output).toContain('npx lingo-tracker preferred-terminology --remove ""'); + }); + it('contains the bundle command with the correct bundle name', () => { expect(substituteSkillTemplate(template, singleCollection)).toContain('npx lingo-tracker bundle --name my-bundle'); }); diff --git a/apps/cli/src/commands/install-skill.ts b/apps/cli/src/commands/install-skill.ts index f727682..2df0ca9 100644 --- a/apps/cli/src/commands/install-skill.ts +++ b/apps/cli/src/commands/install-skill.ts @@ -151,6 +151,14 @@ npx lingo-tracker delete-resource \\ --yes \`\`\` +### Preferred terminology (project-wide) +\`\`\`bash +npx lingo-tracker preferred-terminology --list +npx lingo-tracker preferred-terminology --add "" --preferred "" --reason "" +npx lingo-tracker preferred-terminology --remove "" +\`\`\` +Base-locale values that use a discouraged term get a warning suggesting the preferred term. + ### Other useful commands \`\`\`bash npx lingo-tracker normalize --collection ${primary.name} diff --git a/apps/cli/src/commands/preferred-terminology.spec.ts b/apps/cli/src/commands/preferred-terminology.spec.ts new file mode 100644 index 0000000..49248d4 --- /dev/null +++ b/apps/cli/src/commands/preferred-terminology.spec.ts @@ -0,0 +1,213 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { clearPreferredTerminologyCache } from '@simoncodes-ca/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { preferredTerminologyCommand } from './preferred-terminology'; + +vi.mock('../utils', () => ({ + loadConfiguration: vi.fn(), + ConsoleFormatter: { + section: vi.fn(), + keyValue: vi.fn(), + indent: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + success: vi.fn(), + }, +})); + +import { ConsoleFormatter, loadConfiguration } from '../utils'; + +const FILE_NAME = '.lingo-tracker-preferred-terminology.json'; + +describe('preferredTerminologyCommand', () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + let projectDir: string; + let filePath: string; + let config: Record; + + const writeRules = (content: unknown) => + writeFileSync(filePath, typeof content === 'string' ? content : `${JSON.stringify(content, null, 2)}\n`); + const readRules = () => JSON.parse(readFileSync(filePath, 'utf8')); + const indented = () => vi.mocked(ConsoleFormatter.indent).mock.calls.map((call) => call[0]); + + beforeEach(() => { + vi.clearAllMocks(); + clearPreferredTerminologyCache(); + projectDir = mkdtempSync(join(tmpdir(), 'lingo-preferred-terminology-')); + filePath = join(projectDir, FILE_NAME); + config = { baseLocale: 'en', locales: ['en', 'es'], collections: {} }; + vi.mocked(loadConfiguration).mockImplementation(() => ({ config, cwd: projectDir }) as never); + }); + + afterEach(() => { + rmSync(projectDir, { recursive: true, force: true }); + }); + + describe('argument checks', () => { + it('errors when no option is given', async () => { + await preferredTerminologyCommand({}); + + expect(ConsoleFormatter.error).toHaveBeenCalledWith( + 'Provide one of --list, --add --preferred , or --remove ', + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('rejects --add combined with --remove', async () => { + await preferredTerminologyCommand({ add: 'Expenditure', preferred: 'Investment', remove: 'Spend' }); + + expect(ConsoleFormatter.error).toHaveBeenCalledWith('--add and --remove cannot be combined; run them separately'); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(existsSync(filePath)).toBe(false); + }); + + it('requires --preferred with --add', async () => { + await preferredTerminologyCommand({ add: 'Expenditure' }); + + expect(ConsoleFormatter.error).toHaveBeenCalledWith('--add requires --preferred '); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(existsSync(filePath)).toBe(false); + }); + + it('rejects --preferred or --reason without --add', async () => { + await preferredTerminologyCommand({ list: true, preferred: 'Investment' }); + + expect(ConsoleFormatter.error).toHaveBeenCalledWith('--preferred and --reason can only be used with --add'); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe('--list', () => { + it('prints the file and (none) when there are no rules', async () => { + await preferredTerminologyCommand({ list: true }); + + expect(ConsoleFormatter.keyValue).toHaveBeenCalledWith('File', FILE_NAME); + expect(indented()).toEqual(['(none)']); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('prints one rule per line, with the reason only when present', async () => { + writeRules([ + { discouraged: 'Expenditure', preferred: 'Investment', reason: 'Brand voice' }, + { discouraged: 'Login', preferred: 'Sign in' }, + ]); + + await preferredTerminologyCommand({ list: true }); + + expect(indented()).toEqual(['Expenditure → Investment — Brand voice', 'Login → Sign in']); + }); + + it('prints the load error and exits 1 for a broken file', async () => { + writeRules('{ not json'); + + await preferredTerminologyCommand({ list: true }); + + expect(ConsoleFormatter.error).toHaveBeenCalledWith( + expect.stringContaining('Preferred terminology file is not valid JSON'), + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('prints a warning for a missing explicit file and continues', async () => { + config.preferredTerminologyFile = 'config/terms.json'; + + await preferredTerminologyCommand({ list: true }); + + expect(ConsoleFormatter.warning).toHaveBeenCalledWith( + expect.stringContaining('Preferred terminology file not found'), + ); + expect(ConsoleFormatter.keyValue).toHaveBeenCalledWith('File', join('config', 'terms.json')); + expect(indented()).toEqual(['(none)']); + expect(exitSpy).not.toHaveBeenCalled(); + }); + }); + + describe('--add', () => { + it('creates the file with a new rule and reports "added"', async () => { + await preferredTerminologyCommand({ add: ' Expenditure ', preferred: 'Investment', reason: 'Brand voice' }); + + expect(readRules()).toEqual([{ discouraged: 'Expenditure', preferred: 'Investment', reason: 'Brand voice' }]); + expect(ConsoleFormatter.success).toHaveBeenCalledWith( + `Added preferred terminology rule: Expenditure → Investment — Brand voice (${FILE_NAME})`, + ); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('updates an existing rule matched case-insensitively, replacing it entirely', async () => { + writeRules([ + { discouraged: 'Expenditure', preferred: 'Investment', reason: 'Old reason' }, + { discouraged: 'Login', preferred: 'Sign in' }, + ]); + + await preferredTerminologyCommand({ add: 'expenditure', preferred: 'Spending' }); + + expect(readRules()).toEqual([ + { discouraged: 'expenditure', preferred: 'Spending' }, + { discouraged: 'Login', preferred: 'Sign in' }, + ]); + expect(ConsoleFormatter.success).toHaveBeenCalledWith( + `Updated preferred terminology rule: expenditure → Spending (${FILE_NAME})`, + ); + }); + + it('prints each validation error and leaves the file untouched', async () => { + const original = [{ discouraged: 'Expenditure', preferred: 'Investment' }]; + writeRules(original); + const before = readFileSync(filePath, 'utf8'); + + // Investment → Capital would make "Investment" both preferred and discouraged: a chain. + await preferredTerminologyCommand({ add: 'Investment', preferred: 'Capital' }); + + expect(ConsoleFormatter.error).toHaveBeenCalledWith('Preferred terminology not saved:'); + expect(indented().length).toBeGreaterThan(0); + expect(indented()[0]).toContain('"Expenditure → Investment":'); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(ConsoleFormatter.success).not.toHaveBeenCalled(); + expect(readFileSync(filePath, 'utf8')).toBe(before); + }); + + it('refuses to write over a broken file', async () => { + writeRules('[{"discouraged": 1}]'); + const before = readFileSync(filePath, 'utf8'); + + await preferredTerminologyCommand({ add: 'Expenditure', preferred: 'Investment' }); + + expect(ConsoleFormatter.error).toHaveBeenCalledWith( + expect.stringContaining('Preferred terminology file has invalid rules'), + ); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(readFileSync(filePath, 'utf8')).toBe(before); + }); + }); + + describe('--remove', () => { + it('removes a rule matched case-insensitively', async () => { + writeRules([ + { discouraged: 'Expenditure', preferred: 'Investment' }, + { discouraged: 'Login', preferred: 'Sign in' }, + ]); + + await preferredTerminologyCommand({ remove: 'EXPENDITURE' }); + + expect(readRules()).toEqual([{ discouraged: 'Login', preferred: 'Sign in' }]); + expect(ConsoleFormatter.success).toHaveBeenCalledWith( + `Removed preferred terminology rule: Expenditure → Investment (${FILE_NAME})`, + ); + }); + + it('errors on an unknown term and leaves the file untouched', async () => { + writeRules([{ discouraged: 'Login', preferred: 'Sign in' }]); + const before = readFileSync(filePath, 'utf8'); + + await preferredTerminologyCommand({ remove: 'Expenditure' }); + + expect(ConsoleFormatter.error).toHaveBeenCalledWith( + `No preferred terminology rule for "Expenditure" (${FILE_NAME})`, + ); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(readFileSync(filePath, 'utf8')).toBe(before); + }); + }); +}); diff --git a/apps/cli/src/commands/preferred-terminology.ts b/apps/cli/src/commands/preferred-terminology.ts new file mode 100644 index 0000000..9f4338c --- /dev/null +++ b/apps/cli/src/commands/preferred-terminology.ts @@ -0,0 +1,148 @@ +import { relative } from 'node:path'; +import { + loadPreferredTerminology, + PreferredTerminologyValidationError, + writePreferredTerminology, +} from '@simoncodes-ca/core'; +import type { PreferredTermRule } from '@simoncodes-ca/domain'; +import { ConsoleFormatter, loadConfiguration } from '../utils'; + +export interface PreferredTerminologyOptions { + list?: boolean; + /** Discouraged term to add, or to update when a rule for it already exists (case-insensitive). */ + add?: string; + /** Preferred term for `--add`. Required with `--add`, rejected without it. */ + preferred?: string; + /** Optional reason for `--add`. Rejected without `--add`. */ + reason?: string; + /** Discouraged term whose rule should be removed (case-insensitive). */ + remove?: string; +} + +/** Renders an absolute path relative to the project root, for readable output. */ +function displayPath(filePath: string, cwd: string): string { + const rel = relative(cwd, filePath); + return rel && !rel.startsWith('..') ? rel : filePath; +} + +/** `Expenditure → Investment — reason`, without the reason suffix when there is none. */ +function formatRule(rule: PreferredTermRule): string { + const base = `${rule.discouraged} → ${rule.preferred}`; + return rule.reason ? `${base} — ${rule.reason}` : base; +} + +function sameTerm(a: string, b: string): boolean { + return a.trim().toLowerCase() === b.trim().toLowerCase(); +} + +function fail(message: string): void { + ConsoleFormatter.error(message); + process.exit(1); +} + +export async function preferredTerminologyCommand(options: PreferredTerminologyOptions): Promise { + const hasList = options.list === true; + const hasAdd = options.add !== undefined; + const hasRemove = options.remove !== undefined; + + if (!hasList && !hasAdd && !hasRemove) { + fail('Provide one of --list, --add --preferred , or --remove '); + return; + } + if (hasAdd && hasRemove) { + fail('--add and --remove cannot be combined; run them separately'); + return; + } + if (!hasAdd && (options.preferred !== undefined || options.reason !== undefined)) { + fail('--preferred and --reason can only be used with --add'); + return; + } + if (hasAdd && options.preferred === undefined) { + fail('--add requires --preferred '); + return; + } + + const loaded = loadConfiguration({ exitOnError: false }); + if (!loaded) return; + const { config, cwd } = loaded; + + const result = loadPreferredTerminology(config, cwd); + const where = displayPath(result.filePath, cwd); + + if (result.warning) { + ConsoleFormatter.warning(result.warning); + } + + if (hasList) { + ConsoleFormatter.section('Preferred Terminology'); + ConsoleFormatter.keyValue('File', where); + if (result.error) { + fail(result.error); + return; + } + if (result.rules.length === 0) { + ConsoleFormatter.indent('(none)'); + } else { + for (const rule of result.rules) { + ConsoleFormatter.indent(formatRule(rule)); + } + } + } + + if (!hasAdd && !hasRemove) return; + + // Writing would replace a file we could not read; make the user fix it first. + if (result.error) { + fail(result.error); + return; + } + + const next = [...result.rules]; + let successMessage: string; + + if (hasRemove) { + const term = options.remove ?? ''; + const index = next.findIndex((rule) => sameTerm(rule.discouraged, term)); + if (index === -1) { + fail(`No preferred terminology rule for "${term.trim()}" (${where})`); + return; + } + const [removed] = next.splice(index, 1); + successMessage = `Removed preferred terminology rule: ${formatRule(removed)} (${where})`; + } else { + // Upsert: a rule for the same discouraged term (any casing) is replaced entirely, + // so omitting --reason on an update clears the previous reason. + const rule: PreferredTermRule = { + discouraged: (options.add ?? '').trim(), + preferred: (options.preferred ?? '').trim(), + ...(options.reason?.trim() ? { reason: options.reason.trim() } : {}), + }; + const index = next.findIndex((existing) => sameTerm(existing.discouraged, rule.discouraged)); + if (index === -1) { + next.push(rule); + successMessage = `Added preferred terminology rule: ${formatRule(rule)} (${where})`; + } else { + next[index] = rule; + successMessage = `Updated preferred terminology rule: ${formatRule(rule)} (${where})`; + } + } + + try { + writePreferredTerminology(result.filePath, next); + } catch (error) { + if (error instanceof PreferredTerminologyValidationError) { + ConsoleFormatter.error('Preferred terminology not saved:'); + for (const ruleError of error.errors) { + const row = next[ruleError.index]; + const label = row ? `"${row.discouraged} → ${row.preferred}"` : `row ${ruleError.index + 1}`; + ConsoleFormatter.indent(`${label}: ${ruleError.message}`); + } + process.exit(1); + return; + } + fail(error instanceof Error ? error.message : String(error)); + return; + } + + ConsoleFormatter.success(successMessage); +} diff --git a/apps/cli/src/commands/validate.icu.test.ts b/apps/cli/src/commands/validate.icu.test.ts index 9e6b377..b7bd21d 100644 --- a/apps/cli/src/commands/validate.icu.test.ts +++ b/apps/cli/src/commands/validate.icu.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { validateCommand } from './validate'; import * as fs from 'node:fs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { validateCommand } from './validate'; const fsMocks = vi.hoisted(() => ({ existsSync: vi.fn(), @@ -20,9 +20,14 @@ vi.mock('@simoncodes-ca/core', () => ({ CONFIG_FILENAME: '.lingo-tracker.json', validateResources: vi.fn(), generateValidationSummary: vi.fn(), + loadPreferredTerminology: vi.fn(() => ({ + rules: [], + filePath: '/project/.lingo-tracker-preferred-terminology.json', + })), })); import * as core from '@simoncodes-ca/core'; + const mockValidateResources = vi.mocked(core.validateResources); const mockGenerateValidationSummary = vi.mocked(core.generateValidationSummary); diff --git a/apps/cli/src/commands/validate.test.ts b/apps/cli/src/commands/validate.test.ts index 26bd918..ac4b4c6 100644 --- a/apps/cli/src/commands/validate.test.ts +++ b/apps/cli/src/commands/validate.test.ts @@ -1,7 +1,7 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { validateCommand } from './validate'; -import * as fs from 'node:fs'; const fsMocks = vi.hoisted(() => ({ existsSync: vi.fn(), @@ -21,11 +21,17 @@ vi.mock('@simoncodes-ca/core', () => ({ CONFIG_FILENAME: '.lingo-tracker.json', validateResources: vi.fn(), generateValidationSummary: vi.fn(), + loadPreferredTerminology: vi.fn(() => ({ + rules: [], + filePath: '/project/.lingo-tracker-preferred-terminology.json', + })), })); import * as core from '@simoncodes-ca/core'; + const mockValidateResources = vi.mocked(core.validateResources); const mockGenerateValidationSummary = vi.mocked(core.generateValidationSummary); +const mockLoadPreferredTerminology = vi.mocked(core.loadPreferredTerminology); describe('validateCommand', () => { const mockConfig = { @@ -1218,4 +1224,117 @@ describe('validateCommand', () => { expect(process.exit).not.toHaveBeenCalled(); }); }); + + describe('preferred terminology', () => { + const filePath = '/project/.lingo-tracker-preferred-terminology.json'; + const rules = [{ discouraged: 'Expenditure', preferred: 'Investment' }]; + const passingResult = { + totalResourcesValidated: 6, + totalUniqueKeys: 2, + localesValidated: 3, + collectionsValidated: 2, + statusCounts: { new: 0, translated: 0, stale: 0, verified: 6 }, + failures: [], + warnings: [], + successes: [], + passed: true, + }; + + it('loads the rules and passes them with each collection base locale', async () => { + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({ + ...mockConfig, + collections: { + common: { translationsFolder: 'translations/common' }, + legacy: { translationsFolder: 'translations/legacy', baseLocale: 'en-GB' }, + }, + }), + ); + mockLoadPreferredTerminology.mockReturnValueOnce({ rules, filePath }); + mockValidateResources.mockReturnValue(passingResult); + + await validateCommand({}); + + expect(mockLoadPreferredTerminology).toHaveBeenCalledWith( + expect.objectContaining({ baseLocale: 'en' }), + expect.any(String), + ); + expect(mockValidateResources).toHaveBeenCalledWith( + expect.any(Array), + expect.any(Array), + expect.objectContaining({ + terminology: { rules, loadError: undefined, baseLocaleByCollection: { common: 'en', legacy: 'en-GB' } }, + }), + ); + }); + + it('does not fail when the only problems are terminology findings', async () => { + mockLoadPreferredTerminology.mockReturnValueOnce({ rules, filePath }); + mockValidateResources.mockReturnValue({ + ...passingResult, + terminology: { + warnings: [ + { + key: 'budget.title', + collection: 'common', + locale: 'en', + discouraged: 'Expenditure', + preferred: 'Investment', + message: 'consider "Investment" instead of "Expenditure"', + }, + ], + valuesChecked: 2, + }, + }); + + await validateCommand({}); + + expect(process.exit).not.toHaveBeenCalled(); + }); + + it('passes a load error through and exits 1 when validation reports it', async () => { + mockLoadPreferredTerminology.mockReturnValueOnce({ rules: [], filePath, error: 'not valid JSON' }); + mockValidateResources.mockReturnValue({ + ...passingResult, + passed: false, + terminology: { warnings: [], configError: 'not valid JSON', valuesChecked: 0 }, + }); + + await validateCommand({}); + + expect(mockValidateResources).toHaveBeenCalledWith( + expect.any(Array), + expect.any(Array), + expect.objectContaining({ + terminology: expect.objectContaining({ rules: [], loadError: 'not valid JSON' }), + }), + ); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it('prints the missing-explicit-file warning and skips the check', async () => { + mockLoadPreferredTerminology.mockReturnValueOnce({ + rules: [], + filePath, + warning: 'Preferred terminology file not found: /project/terms.json. Treating as an empty list.', + }); + mockValidateResources.mockReturnValue(passingResult); + + await validateCommand({}); + + expect(console.warn).toHaveBeenCalledWith( + '⚠️ Preferred terminology file not found: /project/terms.json. Treating as an empty list.', + ); + expect(mockValidateResources.mock.calls[0]?.[2].terminology).toBeUndefined(); + expect(process.exit).not.toHaveBeenCalled(); + }); + + it('omits the check entirely when there are no rules', async () => { + mockValidateResources.mockReturnValue(passingResult); + + await validateCommand({}); + + expect(mockValidateResources.mock.calls[0]?.[2].terminology).toBeUndefined(); + }); + }); }); diff --git a/apps/cli/src/commands/validate.ts b/apps/cli/src/commands/validate.ts index 1233304..ec1b763 100644 --- a/apps/cli/src/commands/validate.ts +++ b/apps/cli/src/commands/validate.ts @@ -1,5 +1,5 @@ +import { generateValidationSummary, loadPreferredTerminology, validateResources } from '@simoncodes-ca/core'; import * as path from 'path'; -import { validateResources, generateValidationSummary } from '@simoncodes-ca/core'; import { loadConfiguration } from '../utils'; /** @@ -78,6 +78,8 @@ export interface ValidateCommandOptions { * - Missing metadata → treated as 'new' (FAILURE) * - Value does not compile as ICU for its own locale → FAILURE (unless --skip-icu) * - Translation interpolates different placeholders than its base value → FAILURE (unless --skip-placeholders) + * - Base-locale value uses a discouraged term from the preferred-terminology file → WARNING (never fails) + * - Preferred-terminology file exists but cannot be loaded → FAILURE * * **ICU Validation:** * Status validation asks whether a human approved a translation. It says @@ -87,9 +89,15 @@ export interface ValidateCommandOptions { * marked 'verified' and still throw. Every value, including the base-locale * source, is compiled under the locale it is stored under. * + * **Preferred Terminology:** + * Each collection's base-locale values are scanned for discouraged terms from + * the preferred-terminology file. Findings are advisory — reported once per + * key and rule, never affecting the exit code. A file that exists but cannot be + * loaded is a failure, because then nothing was checked. There is no opt-out flag. + * * **Exit Codes:** - * - 0: All validations passed (all resources verified) - * - 1: Validation failures found OR configuration errors + * - 0: All validations passed (all resources verified); terminology warnings allowed + * - 1: Validation failures found, unreadable preferred-terminology file, OR configuration errors * * **Use Cases:** * - Pre-release quality gate in CI/CD pipelines @@ -179,6 +187,19 @@ export async function validateCommand(options: ValidateCommandOptions): Promise< process.exit(1); } + // Terminology findings are advisory, but a broken rule file is a failure: + // otherwise a typo in the file would silently switch the check off in CI. + const preferredTerminology = loadPreferredTerminology(config, cwd); + if (preferredTerminology.warning) { + console.warn(`⚠️ ${preferredTerminology.warning}`); + } + const baseLocaleByCollection = Object.fromEntries( + Object.entries(config.collections || {}).map(([name, collectionConfig]) => [ + name, + collectionConfig.baseLocale ?? config.baseLocale, + ]), + ); + const compileValues = !options.skipIcu; const requirePortablePlurals = options.requirePortablePlurals ?? false; @@ -200,6 +221,16 @@ export async function validateCommand(options: ValidateCommandOptions): Promise< // A renamed placeholder renders as empty text instead of raising, so the // ICU pass above cannot see it and the status gate has no opinion on it. placeholders: options.skipPlaceholders ? undefined : { baseLocale: config.baseLocale }, + // Omitted when there is nothing to check, so a project without rules sees + // no terminology output at all. + terminology: + preferredTerminology.rules.length > 0 || preferredTerminology.error !== undefined + ? { + rules: preferredTerminology.rules, + loadError: preferredTerminology.error, + baseLocaleByCollection, + } + : undefined, }; const validationResult = validateResources(allCollections, localesToValidate, validationOptions); diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index 45e5153..7100666 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -442,10 +442,13 @@ Validation Rules: ✏️ translated Has translation but not verified → FAILURE (default) → WARNING (--allow-translated) ✅ verified Translation reviewed and approved → SUCCESS + ⚠️ terminology Base value uses a discouraged term → WARNING (never fails) Exit Codes: - 0 All validations passed (all resources verified) - 1 Validation failures found (new/stale/translated resources) + 0 All validations passed (all resources verified); preferred terminology + warnings do not change the exit code + 1 Validation failures found (new/stale/translated resources), or the + preferred terminology file exists but cannot be loaded Notes: - Compiles every stored value under its own locale; values that fail are failures @@ -457,6 +460,10 @@ Notes: value; a renamed one ('{name}' translated to '{nombre}') renders as empty text rather than raising, so no other check sees it. Use --skip-placeholders to turn this off + - Scans each collection's base-locale values for discouraged terms from the + preferred terminology file (.lingo-tracker-preferred-terminology.json, or + preferredTerminologyFile in .lingo-tracker.json). Findings are warnings, + reported once per key and rule; a broken file is a failure. No opt-out flag - --skip-locales excludes target locales only; the base locale is always compiled, since its value is copied into every translation slot - Validates ALL collections and ALL target locales (no filtering) by default @@ -534,6 +541,42 @@ program await protectedTermsCommand(options); }); +program + .command('preferred-terminology') + .description( + 'Manage preferred terminology rules. A base-locale value using a discouraged term gets a warning suggesting the preferred term.', + ) + .option('--list', 'List the rules and the file that holds them') + .option('--add ', 'Add a rule for a discouraged term, or replace the existing one (case-insensitive)') + .option('--preferred ', 'Preferred term for --add (required with --add)') + .option('--reason ', 'Optional reason shown with the suggestion (used with --add)') + .option('--remove ', 'Remove the rule for a discouraged term (case-insensitive)') + .addHelpText( + 'after', + ` +Examples: + # List rules + $ lingo-tracker preferred-terminology --list + + # Add a rule (the file is created if absent) + $ lingo-tracker preferred-terminology --add "Expenditure" --preferred "Investment" --reason "Brand voice" + + # Update a rule: --add on an existing discouraged term replaces the whole rule, + # so omitting --reason clears any previous reason + $ lingo-tracker preferred-terminology --add "expenditure" --preferred "Spending" + + # Remove a rule + $ lingo-tracker preferred-terminology --remove "Expenditure" + +Rules live in .lingo-tracker-preferred-terminology.json beside .lingo-tracker.json, +or in the file named by "preferredTerminologyFile" in .lingo-tracker.json. +`, + ) + .action(async (options) => { + const { preferredTerminologyCommand } = await import('./commands/preferred-terminology'); + await preferredTerminologyCommand(options); + }); + program .command('install-skill') .description('Generate a lingo-tracker AI skill configured for this repository') diff --git a/apps/cli/src/utils/index.ts b/apps/cli/src/utils/index.ts index 757875d..f91edb7 100644 --- a/apps/cli/src/utils/index.ts +++ b/apps/cli/src/utils/index.ts @@ -3,6 +3,7 @@ export * from './collection-resolver'; export * from './config-loader'; export * from './console-formatter'; export * from './error-messages'; +export * from './preferred-terminology-warnings'; export * from './prompt-utils'; export * from './result-aggregator'; export * from './string-parsers'; diff --git a/apps/cli/src/utils/preferred-terminology-warnings.ts b/apps/cli/src/utils/preferred-terminology-warnings.ts new file mode 100644 index 0000000..a4dc57e --- /dev/null +++ b/apps/cli/src/utils/preferred-terminology-warnings.ts @@ -0,0 +1,37 @@ +import { describePreferredTermRule, type LingoTrackerConfig, loadPreferredTerminology } from '@simoncodes-ca/core'; +import { findPreferredTermFindings } from '@simoncodes-ca/domain'; +import { ConsoleFormatter } from './console-formatter'; + +/** + * Prints one warning per discouraged term in a base value that was just written. + * + * Advisory only: the value is already stored, and the exit code is left alone. A + * rule file that cannot be loaded prints one config warning and skips the check; a + * missing explicitly configured file prints its warning and checks against no rules. + * + * @param config - Loaded project configuration (for `preferredTerminologyFile`) + * @param cwd - Directory holding `.lingo-tracker.json` + * @param baseValue - The base-locale value as stored + */ +export function warnAboutPreferredTerminology( + config: Pick, + cwd: string, + baseValue: string, +): void { + const loaded = loadPreferredTerminology(config, cwd); + + if (loaded.error) { + ConsoleFormatter.warning(`Preferred terminology checks skipped: ${loaded.error}`); + return; + } + if (loaded.warning) { + ConsoleFormatter.warning(loaded.warning); + } + + for (const { rule } of findPreferredTermFindings(baseValue, loaded.rules)) { + ConsoleFormatter.warning(`Preferred terminology: ${describePreferredTermRule(rule)}`); + if (rule.reason) { + ConsoleFormatter.indent(rule.reason); + } + } +} diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/preferred-term-advisories/preferred-term-advisories.html b/apps/tracker/src/app/browser/dialogs/translation-editor/preferred-term-advisories/preferred-term-advisories.html new file mode 100644 index 0000000..9410332 --- /dev/null +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/preferred-term-advisories/preferred-term-advisories.html @@ -0,0 +1,37 @@ + +
+ @for (finding of findings(); track finding.rule.discouraged) { +
+ +
+

+ {{ TOKENS.BROWSER.TRANSLATIONEDITOR.PREFERREDTERM.MESSAGEX | transloco: + { preferred: finding.rule.preferred, discouraged: + finding.rule.discouraged } }} +

+ @if (finding.rule.reason) { +

+ {{ finding.rule.reason }} +

+ } +
+ @if (!readOnly()) { + + } +
+ } +
diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/preferred-term-advisories/preferred-term-advisories.scss b/apps/tracker/src/app/browser/dialogs/translation-editor/preferred-term-advisories/preferred-term-advisories.scss new file mode 100644 index 0000000..d46f338 --- /dev/null +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/preferred-term-advisories/preferred-term-advisories.scss @@ -0,0 +1,78 @@ +// Same amber tint as the dialog's lock notice, at the field-hint's scale: this +// is advice beside a field, not a banner over the form. +:host { + display: block; +} + +.term-advisories { + display: flex; + flex-direction: column; + gap: var(--spacing-1); +} + +.term-advisory { + display: flex; + align-items: flex-start; + gap: 6px; + padding: 6px var(--spacing-2); + border: 1px solid color-mix(in srgb, var(--color-warning) 30%, transparent); + border-radius: var(--border-radius-md); + background: color-mix(in srgb, var(--color-warning) 9%, transparent); + color: var(--color-warning-text); + font-size: var(--font-size-xs); + line-height: var(--line-height-normal); +} + +.term-advisory-icon { + flex: none; + font-size: 15px; + width: 15px; + height: 15px; + margin-top: 1px; +} + +.term-advisory-body { + flex: 1; + min-width: 0; +} + +.term-advisory-message, +.term-advisory-reason { + margin: 0; +} + +.term-advisory-reason { + color: var(--color-text-secondary); +} + +// The fix rides the first line, like the key collision's "Open existing", but +// reads as a button: people were mistaking the bare text for part of the note. +.term-advisory-act { + flex: none; + margin: -1px 0; + padding: 2px var(--spacing-2); + border: 1px solid color-mix(in srgb, var(--color-warning) 55%, transparent); + border-radius: var(--border-radius-sm); + background: color-mix(in srgb, var(--color-warning) 18%, transparent); + color: inherit; + font-family: inherit; + font-size: inherit; + font-weight: var(--font-weight-semibold); + line-height: inherit; + white-space: nowrap; + cursor: pointer; + + &:hover { + border-color: color-mix(in srgb, var(--color-warning) 75%, transparent); + background: color-mix(in srgb, var(--color-warning) 28%, transparent); + } + + &:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: 1px; + } + + &:active { + background: color-mix(in srgb, var(--color-warning) 36%, transparent); + } +} diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/preferred-term-advisories/preferred-term-advisories.spec.ts b/apps/tracker/src/app/browser/dialogs/translation-editor/preferred-term-advisories/preferred-term-advisories.spec.ts new file mode 100644 index 0000000..e65681b --- /dev/null +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/preferred-term-advisories/preferred-term-advisories.spec.ts @@ -0,0 +1,66 @@ +import { createComponentFactory, type Spectator } from '@ngneat/spectator/vitest'; +import type { PreferredTermFinding } from '@simoncodes-ca/domain'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getTranslocoTestingModule } from '../../../../../testing/transloco-testing.module'; +import { PreferredTermAdvisories } from './preferred-term-advisories'; + +describe('PreferredTermAdvisories', () => { + let spectator: Spectator; + + const findings: PreferredTermFinding[] = [ + { + rule: { discouraged: 'Expenditure', preferred: 'Investment', reason: 'Former financial-planning term.' }, + ranges: [{ start: 0, end: 11 }], + }, + { rule: { discouraged: 'Custom Field', preferred: 'Configurable Field' }, ranges: [{ start: 16, end: 28 }] }, + ]; + + const createComponent = createComponentFactory({ + component: PreferredTermAdvisories, + imports: [getTranslocoTestingModule()], + detectChanges: false, + }); + + beforeEach(() => { + spectator = createComponent({ props: { findings, advisoryId: 'advice' } }); + spectator.detectChanges(); + }); + + it('should stamp the id the field describes itself with', () => { + expect(spectator.query('#advice')).not.toBeNull(); + }); + + it('should render one advisory per finding with the configured copy', () => { + const items = spectator.queryAll('[data-testid="preferred-term-advisory"]'); + + expect(items).toHaveLength(2); + expect(items[0].textContent).toContain('Preferred terminology: consider “Investment” instead of “Expenditure”.'); + expect(items[0].textContent).toContain('Former financial-planning term.'); + expect(items[1].querySelector('[data-testid="preferred-term-reason"]')).toBeNull(); + }); + + it('should name each button after its preferred term', () => { + const labels = spectator + .queryAll('[data-testid="preferred-term-use"]') + .map((button) => button.textContent?.trim()); + + expect(labels).toEqual(['Use “Investment”', 'Use “Configurable Field”']); + }); + + it('should emit the rule when Use is clicked', () => { + const applied = vi.fn(); + spectator.output('applyRule').subscribe(applied); + + spectator.click(spectator.queryAll('[data-testid="preferred-term-use"]')[1]); + + expect(applied).toHaveBeenCalledWith(findings[1].rule); + }); + + it('should hide Use when read-only', () => { + spectator.setInput('readOnly', true); + spectator.detectChanges(); + + expect(spectator.query('[data-testid="preferred-term-use"]')).toBeNull(); + expect(spectator.queryAll('[data-testid="preferred-term-advisory"]')).toHaveLength(2); + }); +}); diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/preferred-term-advisories/preferred-term-advisories.ts b/apps/tracker/src/app/browser/dialogs/translation-editor/preferred-term-advisories/preferred-term-advisories.ts new file mode 100644 index 0000000..779877a --- /dev/null +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/preferred-term-advisories/preferred-term-advisories.ts @@ -0,0 +1,37 @@ +import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { TranslocoPipe } from '@jsverse/transloco'; +import type { PreferredTermFinding, PreferredTermRule } from '@simoncodes-ca/domain'; +import { TRACKER_TOKENS } from '../../../../../i18n-types/tracker-resources'; + +/** + * The amber notes under the base-locale value: one per preferred-terminology + * rule the value breaks, each with a one-click fix. + * + * Advisory only. The host decides when findings change (after a typing pause), + * and applies the rule itself; this component never touches the form. + * + * Deliberately not a live region: the host wires `advisoryId` into the field's + * `aria-describedby`, so a screen reader hears the advice when it reads the + * field rather than on every keystroke that changes it. + */ +@Component({ + selector: 'app-preferred-term-advisories', + templateUrl: './preferred-term-advisories.html', + styleUrl: './preferred-term-advisories.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatIconModule, TranslocoPipe], +}) +export class PreferredTermAdvisories { + /** One finding per matched rule, in rule order. */ + readonly findings = input.required(); + /** Id of the container, referenced by the field's `aria-describedby`. */ + readonly advisoryId = input.required(); + /** Hides the fix when the field cannot be edited. */ + readonly readOnly = input(false); + + /** The user asked to replace the rule's discouraged term with its preferred one. */ + readonly applyRule = output(); + + readonly TOKENS = TRACKER_TOKENS; +} diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.html b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.html index b21b047..0f4614a 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.html +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.html @@ -158,9 +158,7 @@

{{ dialogTitle() | transloco }}

TOKENS.BROWSER.TRANSLATIONEDITOR.ENTERTRANSLATIONX | transloco: { locale: baseLocaleName() } " [attr.aria-invalid]="showBaseValueError()" - [attr.aria-describedby]=" - showBaseValueError() ? 'translation-editor-base-value-error' : 'translation-editor-icu-hint' - " + [attr.aria-describedby]="baseValueDescribedBy()" > @if (showBaseValueError()) { @@ -178,6 +176,14 @@

{{ dialogTitle() | transloco }}

>

} + @if (preferredTermFindings().length > 0) { + + } diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.spec.ts b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.spec.ts index a1e3de1..918a4f4 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.spec.ts +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.spec.ts @@ -1,21 +1,25 @@ import { HttpErrorResponse, provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { signal, type WritableSignal } from '@angular/core'; import type { ComponentFixture } from '@angular/core/testing'; import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { createComponentFactory, type Spectator } from '@ngneat/spectator/vitest'; -import type { ResourceSummaryDto } from '@simoncodes-ca/data-transfer'; import { patchState } from '@ngrx/signals'; +import type { LingoTrackerConfigDto, ResourceSummaryDto } from '@simoncodes-ca/data-transfer'; import { of, Subject, throwError } from 'rxjs'; import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; import { TRACKER_TOKENS } from '../../../../i18n-types/tracker-resources'; import { getTranslocoTestingModule } from '../../../../testing/transloco-testing.module'; +import { CollectionsStore } from '../../../collections/store/collections.store'; import { NotificationService } from '../../../shared/notification'; import { BrowserApiService } from '../../services/browser-api.service'; import { BrowserStore } from '../../store/browser.store'; import { - TranslationEditorDialog, + PREFERRED_TERM_ADVISORIES_ID, + PREFERRED_TERM_DEBOUNCE_MS, TRANSLATION_EDITOR_TITLE_ID, + TranslationEditorDialog, type TranslationEditorDialogData, type TranslationEditorResult, } from './translation-editor-dialog'; @@ -33,6 +37,7 @@ describe('TranslationEditorDialog', () => { getResourceTree: Mock; }; let mockNotifications: { success: Mock; info: Mock; warning: Mock; error: Mock }; + let mockConfig: WritableSignal; const createMockData = (mode: 'create' | 'edit', resource?: ResourceSummaryDto): TranslationEditorDialogData => ({ mode, @@ -57,6 +62,7 @@ describe('TranslationEditorDialog', () => { { provide: MatDialog, useFactory: () => mockDialog }, { provide: BrowserApiService, useFactory: () => mockBrowserApi }, { provide: NotificationService, useFactory: () => mockNotifications }, + { provide: CollectionsStore, useFactory: () => ({ config: mockConfig }) }, { provide: MAT_DIALOG_DATA, useValue: dialogData }, ], detectChanges: false, @@ -96,6 +102,7 @@ describe('TranslationEditorDialog', () => { }; mockNotifications = { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; + mockConfig = signal(null); renderDialog(createMockData('create')); }); @@ -1849,4 +1856,224 @@ describe('TranslationEditorDialog', () => { expect(spectator.query('[data-testid="footer-key"]')).toHaveClass('mono--dup'); }); }); + describe('Preferred terminology advisories', () => { + const expenditure = { + discouraged: 'Expenditure', + preferred: 'Investment', + reason: 'Former financial-planning term.', + }; + const customField = { discouraged: 'Custom Field', preferred: 'Configurable Field' }; + + const useRules = (rules: LingoTrackerConfigDto['preferredTerminology'], error?: string): void => { + mockConfig.set({ + baseLocale: 'en', + locales: ['en', 'fr', 'de'], + collections: {}, + preferredTerminology: rules, + preferredTerminologyError: error, + } as LingoTrackerConfigDto); + }; + + const advisories = (): HTMLElement[] => spectator.queryAll('[data-testid="preferred-term-advisory"]'); + const baseTextarea = (): HTMLTextAreaElement | null => + spectator.query('#translation-editor-base-value'); + + const openEditing = (baseValue: string): void => { + renderDialog(createMockData('edit', { key: 'label', translations: { en: baseValue }, status: {} })); + }; + + const type = (value: string, settle = true): void => { + component.form.controls.baseValue.setValue(value); + if (settle) { + vi.advanceTimersByTime(PREFERRED_TERM_DEBOUNCE_MS); + } + spectator.detectChanges(); + }; + + beforeEach(() => { + useRules([expenditure, customField]); + renderDialog(createMockData('create')); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should wait for a typing pause before advising', () => { + component.form.controls.baseValue.setValue('Capital Expenditure'); + vi.advanceTimersByTime(PREFERRED_TERM_DEBOUNCE_MS - 1); + spectator.detectChanges(); + expect(advisories()).toHaveLength(0); + + vi.advanceTimersByTime(1); + spectator.detectChanges(); + + expect(advisories()).toHaveLength(1); + expect(advisories()[0].textContent).toContain( + 'Preferred terminology: consider “Investment” instead of “Expenditure”.', + ); + expect(spectator.query('[data-testid="preferred-term-use"]')?.textContent?.trim()).toBe('Use “Investment”'); + }); + + it('should advise at once when an existing value opens', () => { + openEditing('Review the expenditure'); + + expect(advisories()).toHaveLength(1); + }); + + it('should show one advisory per matched rule', () => { + type('Expenditure on the Custom Field, and more expenditure'); + + expect(advisories()).toHaveLength(2); + expect(advisories()[0].textContent).toContain('“Expenditure”'); + expect(advisories()[1].textContent).toContain('“Custom Field”'); + }); + + it('should show the reason only when the rule has one', () => { + type('Expenditure on the Custom Field'); + + const [withReason, withoutReason] = advisories(); + expect(withReason.querySelector('[data-testid="preferred-term-reason"]')?.textContent?.trim()).toBe( + 'Former financial-planning term.', + ); + expect(withoutReason.querySelector('[data-testid="preferred-term-reason"]')).toBeNull(); + }); + + it('should replace every occurrence on Use without saving', () => { + component.form.controls.key.setValue('label'); + type('Expenditure, expenditure-report and {expenditure} stay'); + + spectator.click('[data-testid="preferred-term-use"]'); + spectator.detectChanges(); + + expect(component.form.controls.baseValue.value).toBe('Investment, Investment-report and {expenditure} stay'); + expect(component.form.controls.baseValue.dirty).toBe(true); + expect(mockBrowserApi.createResource).not.toHaveBeenCalled(); + expect(mockBrowserApi.updateResource).not.toHaveBeenCalled(); + expect(dialogRef.close).not.toHaveBeenCalled(); + // Gone without waiting out the debounce. + expect(advisories()).toHaveLength(0); + expect(spectator.query('[data-testid="submit"]')?.disabled).toBe(false); + expect(component.isFormValid()).toBe(true); + }); + + it('should hand focus back to the field after Use', () => { + type('Expenditure'); + + spectator.click('[data-testid="preferred-term-use"]'); + vi.advanceTimersByTime(0); + + expect(document.activeElement).toBe(baseTextarea()); + }); + + it('should run the normal value-change flow on Use', () => { + type('Total expenditure for the year'); + mockBrowserApi.searchTranslations.mockClear(); + + spectator.click('[data-testid="preferred-term-use"]'); + vi.advanceTimersByTime(300); + + expect(component.baseValueText()).toBe('Total Investment for the year'); + expect(mockBrowserApi.searchTranslations).toHaveBeenCalledWith( + 'test-collection', + 'Total Investment for the year', + expect.any(Number), + ); + }); + + it('should leave only the untouched rule after Use', () => { + type('Expenditure on the Custom Field'); + + spectator.click('[data-testid="preferred-term-use"]'); + spectator.detectChanges(); + + expect(advisories()).toHaveLength(1); + expect(advisories()[0].textContent).toContain('“Custom Field”'); + }); + + it('should drop the advisory once the term is removed', () => { + type('Expenditure'); + expect(advisories()).toHaveLength(1); + + type('Investment'); + + expect(advisories()).toHaveLength(0); + }); + + it('should not block saving or make the field invalid', () => { + component.form.controls.key.setValue('label'); + component.form.controls.comment.setValue('A comment'); + type('Expenditure'); + + expect(component.form.controls.baseValue.valid).toBe(true); + expect(component.isFormValid()).toBe(true); + + void component.onSubmit(); + + expect(mockBrowserApi.createResource).toHaveBeenCalled(); + expect(mockDialog.open).not.toHaveBeenCalled(); + }); + + it('should render nothing without rules', () => { + useRules(undefined); + openEditing('Expenditure'); + + expect(spectator.query('app-preferred-term-advisories')).toBeNull(); + }); + + it('should render nothing when the rule file failed to load', () => { + useRules(undefined, 'Invalid JSON'); + openEditing('Expenditure'); + + expect(spectator.query('app-preferred-term-advisories')).toBeNull(); + }); + + it('should describe the field with the advisories only while they exist', () => { + expect(baseTextarea()?.getAttribute('aria-describedby')).toBe('translation-editor-icu-hint'); + + type('Expenditure'); + + const container = spectator.query(`#${PREFERRED_TERM_ADVISORIES_ID}`); + expect(container).not.toBeNull(); + expect(baseTextarea()?.getAttribute('aria-describedby')).toBe( + `translation-editor-icu-hint ${PREFERRED_TERM_ADVISORIES_ID}`, + ); + + type('Investment'); + + expect(baseTextarea()?.getAttribute('aria-describedby')).toBe('translation-editor-icu-hint'); + }); + + it('should keep the base-value error in the description alongside the advisories', () => { + type('Expenditure'); + component.submitAttempted.set(true); + component.form.controls.baseValue.setErrors({ required: true }); + component.formRevision.update((revision) => revision + 1); + spectator.detectChanges(); + + expect(baseTextarea()?.getAttribute('aria-describedby')).toBe( + `translation-editor-base-value-error ${PREFERRED_TERM_ADVISORIES_ID}`, + ); + }); + + it('should not announce advisories as a live region', () => { + type('Expenditure on the Custom Field'); + + const advisoryRoot = spectator.query('app-preferred-term-advisories'); + expect(advisoryRoot?.querySelector('[aria-live]')).toBeNull(); + expect(advisoryRoot?.querySelector('[role="status"], [role="alert"], [role="log"]')).toBeNull(); + expect(advisoryRoot?.closest('[aria-live]')).toBeNull(); + }); + + it('should advise without offering Use when read-only', () => { + renderDialog({ + ...createMockData('edit', { key: 'label', translations: { en: 'Expenditure' }, status: {} }), + readOnly: true, + }); + + expect(advisories()).toHaveLength(1); + expect(spectator.query('[data-testid="preferred-term-use"]')).toBeNull(); + }); + }); }); diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts index 99b25b4..1c285b7 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts @@ -1,52 +1,59 @@ +import { OverlayModule } from '@angular/cdk/overlay'; +import { TextFieldModule } from '@angular/cdk/text-field'; +import { CommonModule } from '@angular/common'; +import { HttpErrorResponse } from '@angular/common/http'; import { - Component, + type AfterViewInit, ChangeDetectionStrategy, + Component, + computed, + type ElementRef, + HostListener, inject, - type OnInit, type OnDestroy, - type AfterViewInit, + type OnInit, signal, - computed, - HostListener, ViewChild, - type ElementRef, } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { ReactiveFormsModule, FormGroup, FormControl, Validators, FormArray } from '@angular/forms'; -import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA, MatDialog } from '@angular/material/dialog'; +import { FormArray, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatAutocompleteModule, type MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; import { MatButtonModule } from '@angular/material/button'; +import { MAT_DIALOG_DATA, MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatAutocompleteModule, type MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { OverlayModule } from '@angular/cdk/overlay'; -import { TextFieldModule } from '@angular/cdk/text-field'; -import { NotificationService } from '../../../shared/notification'; +import { TranslocoPipe, TranslocoService } from '@jsverse/transloco'; import type { - ResourceSummaryDto, - TranslationStatus, CreateResourceDto, CreateResourceResponseDto, + FolderNodeDto, + ResourceSummaryDto, + SearchResultDto, + TranslationStatus, UpdateResourceDto, UpdateResourceResponseDto, - SearchResultDto, - FolderNodeDto, } from '@simoncodes-ca/data-transfer'; -import { BrowserApiService } from '../../services/browser-api.service'; -import { BrowserStore } from '../../store/browser.store'; -import { HttpErrorResponse } from '@angular/common/http'; +import { + applyPreferredTerm, + findPreferredTermFindings, + isValidSegment, + normalizeTag, + type PreferredTermRule, +} from '@simoncodes-ca/domain'; +import { of, Subject } from 'rxjs'; +import { catchError, debounceTime, distinctUntilChanged, switchMap, takeUntil, tap } from 'rxjs/operators'; +import { TRACKER_TOKENS } from '../../../../i18n-types/tracker-resources'; +import { CollectionsStore } from '../../../collections/store/collections.store'; import { ConfirmationDialog } from '../../../shared/components/confirmation-dialog/confirmation-dialog'; import type { ConfirmationDialogData } from '../../../shared/components/confirmation-dialog/confirmation-dialog-data'; -import { TranslocoPipe, TranslocoService } from '@jsverse/transloco'; -import { TRACKER_TOKENS } from '../../../../i18n-types/tracker-resources'; +import { NotificationService } from '../../../shared/notification'; +import { BrowserApiService } from '../../services/browser-api.service'; +import { BrowserStore } from '../../store/browser.store'; +import { FolderPicker } from './folder-picker/folder-picker'; +import { PreferredTermAdvisories } from './preferred-term-advisories/preferred-term-advisories'; import { SimilarTranslations } from './similar-translations'; import { filterSimilarByValue, SIMILAR_SEARCH_MAX_RESULTS } from './similar-value-filter'; -import { FolderPicker } from './folder-picker/folder-picker'; -import { Subject } from 'rxjs'; -import { debounceTime, distinctUntilChanged, switchMap, catchError, takeUntil, tap } from 'rxjs/operators'; -import { of } from 'rxjs'; -import { isValidSegment, normalizeTag } from '@simoncodes-ca/domain'; /** * The id of the dialog's heading. The MatDialog container is labelled by this id @@ -55,6 +62,12 @@ import { isValidSegment, normalizeTag } from '@simoncodes-ca/domain'; */ export const TRANSLATION_EDITOR_TITLE_ID = 'translation-editor-title'; +/** Id of the preferred-terminology advisories, joined to the base value's `aria-describedby`. */ +export const PREFERRED_TERM_ADVISORIES_ID = 'translation-editor-preferred-terms'; + +/** Typing pause before preferred-terminology findings refresh; matches the similar search. */ +export const PREFERRED_TERM_DEBOUNCE_MS = 300; + export interface TranslationEditorDialogData { mode: 'create' | 'edit'; resource?: ResourceSummaryDto; @@ -127,6 +140,7 @@ export interface TranslationEditorResult { FolderPicker, TranslocoPipe, MatTooltipModule, + PreferredTermAdvisories, ], }) export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit { @@ -139,6 +153,7 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit private readonly browserStore = inject(BrowserStore); private readonly notifications = inject(NotificationService); private readonly transloco = inject(TranslocoService); + readonly #collectionsStore = inject(CollectionsStore); private readonly destroy$ = new Subject(); private readonly baseValueSearch$ = new Subject(); @@ -207,6 +222,45 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit /** Folders whose entries are in flight. A folder in here claims no collision yet. */ readonly #loadingFolders = signal>(new Set()); + /** + * The base value preferred terminology is checked against. Lags the field by a + * typing pause, except on open and after "Use …", where it is set at once. + */ + readonly #terminologyCheckedValue = signal(''); + + /** + * Rules from `GET /config`. A rule file that failed to load yields none (D1): + * the Settings page reports the error, the editor stays quiet. + */ + readonly #preferredTermRules = computed(() => { + const config = this.#collectionsStore.config(); + if (!config || config.preferredTerminologyError) { + return []; + } + return config.preferredTerminology ?? []; + }); + + /** One finding per rule the base value breaks. Advice only: never feeds validity. */ + readonly preferredTermFindings = computed(() => { + const rules = this.#preferredTermRules(); + const value = this.#terminologyCheckedValue(); + return rules.length > 0 && value ? findPreferredTermFindings(value, rules) : []; + }); + + readonly preferredTermAdvisoriesId = PREFERRED_TERM_ADVISORIES_ID; + + /** + * The base value's `aria-describedby`: the error or ICU hint as before, plus + * the advisories while there are any. + */ + readonly baseValueDescribedBy = computed(() => { + const ids = [this.showBaseValueError() ? 'translation-editor-base-value-error' : 'translation-editor-icu-hint']; + if (this.preferredTermFindings().length > 0) { + ids.push(PREFERRED_TERM_ADVISORIES_ID); + } + return ids.join(' '); + }); + readonly tagInputText = signal(''); readonly tagsList = signal([]); readonly inheritedTagsList = computed(() => this.data.resource?.inheritedTags ?? []); @@ -544,6 +598,7 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit this.#originalFolderPath = this.selectedFolderPath(); this.#setupSimilarResourcesSearch(); + this.#setupPreferredTermCheck(); if (!this.isEditMode()) { this.#setupDottedKeyAbsorption(); @@ -722,6 +777,39 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit }); } + /** + * Findings follow the base value after a typing pause, so the notes do not + * flicker per keystroke. An existing value is checked at once, so an entry + * that already uses a discouraged term says so as soon as it opens. + */ + #setupPreferredTermCheck(): void { + const control = this.form.controls.baseValue; + this.#terminologyCheckedValue.set(control.value); + control.valueChanges + .pipe(debounceTime(PREFERRED_TERM_DEBOUNCE_MS), takeUntil(this.destroy$)) + .subscribe((value) => this.#terminologyCheckedValue.set(value)); + } + + /** + * "Use …": rewrites the rule's discouraged term to the preferred spelling + * through the ordinary value-change path, as if typed, and never saves. The + * note goes at once rather than after the debounce, and the caret goes back + * to the field because the button it was on no longer exists. + */ + onApplyPreferredTerm(rule: PreferredTermRule): void { + if (this.isReadOnly()) { + return; + } + const control = this.form.controls.baseValue; + const next = applyPreferredTerm(control.value, rule); + if (next !== control.value) { + control.markAsDirty(); + control.setValue(next); + } + this.#terminologyCheckedValue.set(next); + this.#focusOnceRendered(() => this.baseValueInput?.nativeElement); + } + /** * The primary user arrives holding a full dotted key — `apps.common.buttons.ok` — * and the key control only accepts a single segment. Rather than rejecting the diff --git a/apps/tracker/src/app/collections/store/collections.store.spec.ts b/apps/tracker/src/app/collections/store/collections.store.spec.ts index 816b164..3410582 100644 --- a/apps/tracker/src/app/collections/store/collections.store.spec.ts +++ b/apps/tracker/src/app/collections/store/collections.store.spec.ts @@ -1,3 +1,4 @@ +import { HttpErrorResponse } from '@angular/common/http'; import { createServiceFactory, type SpectatorService } from '@ngneat/spectator/vitest'; import type { LingoTrackerConfigDto } from '@simoncodes-ca/data-transfer'; import { of, throwError } from 'rxjs'; @@ -59,4 +60,45 @@ describe('CollectionsStore', () => { expect(store.error()).toBe('save failed'); expect(store.config()).toBeNull(); }); + + it('updateGlobalConfig exposes per-row preferred terminology errors from a 400 body', () => { + const errors = [{ index: 1, field: 'discouraged', code: 'duplicate', message: 'dup' }]; + api.updateConfig.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: 400, + error: { message: 'Invalid preferred terminology rules', errors }, + }), + ), + ); + + store.updateGlobalConfig({ preferredTerminology: [] }); + + expect(store.configRuleErrors()).toEqual(errors); + expect(store.error()).toBeTruthy(); + }); + + it('updateGlobalConfig clears rule errors when a new save starts', () => { + api.updateConfig.mockReturnValueOnce( + throwError(() => new HttpErrorResponse({ status: 400, error: { message: 'x', errors: [{ index: 0 }] } })), + ); + store.updateGlobalConfig({ preferredTerminology: [] }); + api.updateConfig.mockReturnValue(of({ message: 'ok' })); + api.getConfig.mockReturnValue(of(configAfterSave)); + + store.updateGlobalConfig({ preferredTerminology: [] }); + + expect(store.configRuleErrors()).toEqual([]); + }); + + it('updateGlobalConfig leaves rule errors empty for failures without an errors array', () => { + api.updateConfig.mockReturnValue( + throwError(() => new HttpErrorResponse({ status: 400, error: { message: 'protectedTerms must be…' } })), + ); + + store.updateGlobalConfig({ protectedTerms: ['x'] }); + + expect(store.configRuleErrors()).toEqual([]); + }); }); diff --git a/apps/tracker/src/app/collections/store/collections.store.ts b/apps/tracker/src/app/collections/store/collections.store.ts index df2389e..a3f119e 100644 --- a/apps/tracker/src/app/collections/store/collections.store.ts +++ b/apps/tracker/src/app/collections/store/collections.store.ts @@ -1,19 +1,20 @@ -import { computed } from '@angular/core'; -import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals'; -import { rxMethod } from '@ngrx/signals/rxjs-interop'; -import { pipe, tap, switchMap, catchError, of } from 'rxjs'; -import { inject } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; +import { computed, inject } from '@angular/core'; import { TranslocoService } from '@jsverse/transloco'; -import { CollectionsApiService } from '../services/collections-api.service'; -import { withBundlesFeature } from './features/with-bundles.feature'; -import { TRACKER_TOKENS } from '../../../i18n-types/tracker-resources'; +import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; import type { + CreateCollectionDto, LingoTrackerCollectionDto, LingoTrackerConfigDto, - CreateCollectionDto, + PreferredTermRuleErrorDto, UpdateCollectionDto, UpdateConfigDto, } from '@simoncodes-ca/data-transfer'; +import { catchError, of, pipe, switchMap, tap } from 'rxjs'; +import { TRACKER_TOKENS } from '../../../i18n-types/tracker-resources'; +import { CollectionsApiService } from '../services/collections-api.service'; +import { withBundlesFeature } from './features/with-bundles.feature'; /** * State interface for the Collections store. @@ -27,6 +28,21 @@ interface CollectionsState { /** Error message if an operation fails */ error: string | null; + + /** + * Per-row preferred-terminology errors from the last failed `updateGlobalConfig`, indexed + * by row of the submitted list. Empty unless the server rejected the rules with a 400. + */ + configRuleErrors: PreferredTermRuleErrorDto[]; +} + +/** Extracts the `errors` array of a 400 `{ message, errors }` body from `PUT /api/config`, if present. */ +function extractRuleErrors(error: unknown): PreferredTermRuleErrorDto[] { + if (!(error instanceof HttpErrorResponse) || error.status !== 400) return []; + const body: unknown = error.error; + if (typeof body !== 'object' || body === null) return []; + const errors = (body as { errors?: unknown }).errors; + return Array.isArray(errors) ? (errors as PreferredTermRuleErrorDto[]) : []; } /** @@ -36,6 +52,7 @@ const initialState: CollectionsState = { config: null, isLoading: false, error: null, + configRuleErrors: [], }; /** @@ -214,7 +231,7 @@ export const CollectionsStore = signalStore( */ updateGlobalConfig: rxMethod( pipe( - tap(() => patchState(store, { isLoading: true, error: null })), + tap(() => patchState(store, { isLoading: true, error: null, configRuleErrors: [] })), switchMap((dto) => api.updateConfig(dto).pipe( tap(() => { @@ -235,6 +252,7 @@ export const CollectionsStore = signalStore( patchState(store, { isLoading: false, error: errorMessage, + configRuleErrors: extractRuleErrors(error), }); return of(null); }), diff --git a/apps/tracker/src/app/settings/preferred-terminology-draft.ts b/apps/tracker/src/app/settings/preferred-terminology-draft.ts new file mode 100644 index 0000000..f0aa32e --- /dev/null +++ b/apps/tracker/src/app/settings/preferred-terminology-draft.ts @@ -0,0 +1,259 @@ +import { computed, signal } from '@angular/core'; +import { + findPreferredTermFindings, + normalizePreferredTermRules, + type PreferredTermRule, + type PreferredTermRuleError, + validatePreferredTermRules, +} from '@simoncodes-ca/domain'; + +export type RuleField = 'discouraged' | 'preferred' | 'reason'; + +const RULE_FIELDS: readonly RuleField[] = ['discouraged', 'preferred', 'reason']; + +/** + * One editable rule row. `original` is the rule as last saved — absent for a row added in + * this session. `touched` holds the fields the user has typed in or left, so a blank new + * row does not open with "Required" under every input. + */ +export interface RuleRow { + readonly id: number; + readonly discouraged: string; + readonly preferred: string; + readonly reason: string; + readonly original?: PreferredTermRule; + readonly touched: ReadonlySet; +} + +/** An error ready to render: the code picks the message, `params` fill it. */ +export interface RuleFieldError { + readonly code: PreferredTermRuleError['code']; + readonly params: Readonly>; +} + +/** A row plus the errors currently shown under each of its fields. */ +export interface RuleRowView { + readonly row: RuleRow; + /** 1-based position, used in screen-reader labels. */ + readonly position: number; + readonly status: 'added' | 'edited' | 'unchanged'; + readonly errors: Readonly>>; +} + +/** + * Staged edits to the preferred-terminology list, validated live with the same domain + * check the server runs. + * + * A pure signal model, owned by the Settings view, so the page-level save bar can combine + * it with the protected-terms edits. Nothing here talks to the API. + * + * Error visibility: an error on a field shows once that field is touched, once the row + * came from the file (an untouched saved row only errors because another row changed), + * or once a save was attempted. Server errors are always shown until their field is edited. + */ +export class PreferredTerminologyDraft { + readonly #rows = signal([]); + readonly #baseline = signal([]); + readonly #submitAttempted = signal(false); + /** Server errors keyed by row id; a field's entry is dropped when that field is edited. */ + readonly #serverErrors = signal>(new Map()); + /** Row ids in the order they were last submitted, so server error indexes map back to rows. */ + #submittedIds: number[] = []; + #nextId = 0; + + readonly rows = this.#rows.asReadonly(); + + /** Every client-side error, keyed by row id. */ + readonly #clientErrors = computed(() => { + const rows = this.#rows(); + const byRow = new Map(); + for (const error of validatePreferredTermRules(rows.map(toRule))) { + const row = rows[error.index]; + if (!row) continue; + byRow.set(row.id, [...(byRow.get(row.id) ?? []), error]); + } + return byRow; + }); + + readonly hasErrors = computed(() => this.#clientErrors().size > 0 || this.#serverErrors().size > 0); + + readonly rowViews = computed(() => { + const rows = this.#rows(); + const clientErrors = this.#clientErrors(); + const serverErrors = this.#serverErrors(); + const submitAttempted = this.#submitAttempted(); + + return rows.map((row, index) => { + const errors: Partial> = {}; + const visible = (field: RuleField) => submitAttempted || row.original !== undefined || row.touched.has(field); + + for (const error of clientErrors.get(row.id) ?? []) { + const field = error.field === 'rule' ? 'discouraged' : error.field; + if (!errors[field] && visible(field)) errors[field] = describe(error, row, rows); + } + for (const error of serverErrors.get(row.id) ?? []) { + const field = error.field === 'rule' ? 'discouraged' : error.field; + if (!errors[field]) errors[field] = describe(error, row, rows); + } + + return { row, position: index + 1, status: statusOf(row), errors }; + }); + }); + + readonly hasVisibleErrors = computed(() => + this.rowViews().some((view) => RULE_FIELDS.some((field) => view.errors[field] !== undefined)), + ); + + /** The list a save would send: every row, trimmed, in display order. The server sorts on write. */ + readonly rulesToSave = computed(() => normalizePreferredTermRules(this.#rows().map(toRule))); + + /** Rows the file would gain, lose or change. A blank added row counts: saving it must be refused visibly. */ + readonly changeCount = computed(() => { + const rows = this.#rows(); + const kept = rows.filter((row) => row.original !== undefined).length; + const removed = this.#baseline().length - kept; + return removed + rows.filter((row) => statusOf(row) !== 'unchanged').length; + }); + + readonly hasChanges = computed(() => this.changeCount() > 0); + readonly isEmpty = computed(() => this.#rows().length === 0); + + /** Replaces every row with `rules` as the new saved baseline, dropping all edits and errors. */ + seed(rules: readonly PreferredTermRule[]): void { + const baseline = normalizePreferredTermRules(rules); + this.#baseline.set(baseline); + this.#rows.set( + baseline.map((rule) => ({ + id: this.#nextId++, + discouraged: rule.discouraged, + preferred: rule.preferred, + reason: rule.reason ?? '', + original: rule, + touched: new Set(), + })), + ); + this.#submitAttempted.set(false); + this.#serverErrors.set(new Map()); + this.#submittedIds = []; + } + + /** Restores the last saved list. */ + revert(): void { + this.seed(this.#baseline()); + } + + /** Appends a blank row and returns its id. */ + addRow(): number { + const id = this.#nextId++; + this.#rows.update((rows) => [ + ...rows, + { id, discouraged: '', preferred: '', reason: '', touched: new Set() }, + ]); + return id; + } + + removeRow(id: number): void { + this.#rows.update((rows) => rows.filter((row) => row.id !== id)); + this.#dropServerErrors(id); + } + + updateField(id: number, field: RuleField, value: string): void { + this.#rows.update((rows) => + rows.map((row) => (row.id === id ? { ...row, [field]: value, touched: withField(row.touched, field) } : row)), + ); + this.#dropServerErrors(id, field); + } + + /** Marks a field as visited (on blur), so leaving a required field empty shows its error. */ + touch(id: number, field: RuleField): void { + const row = this.#rows().find((candidate) => candidate.id === id); + if (!row || row.touched.has(field)) return; + this.#rows.update((rows) => + rows.map((candidate) => + candidate.id === id ? { ...candidate, touched: withField(candidate.touched, field) } : candidate, + ), + ); + } + + /** Shows every error, including those on untouched fields. Called when a save is refused. */ + revealErrors(): void { + this.#submitAttempted.set(true); + } + + /** Records which row each submitted index belongs to and returns the payload. */ + beginSave(): PreferredTermRule[] { + this.#submittedIds = this.#rows().map((row) => row.id); + return this.rulesToSave(); + } + + /** Attaches server errors to the rows that were submitted at those indexes. */ + applyServerErrors(errors: readonly PreferredTermRuleError[]): void { + const byRow = new Map(); + for (const error of errors) { + const id = this.#submittedIds[error.index]; + if (id === undefined || !this.#rows().some((row) => row.id === id)) continue; + byRow.set(id, [...(byRow.get(id) ?? []), error]); + } + this.#serverErrors.set(byRow); + this.#submitAttempted.set(true); + } + + #dropServerErrors(id: number, field?: RuleField): void { + const current = this.#serverErrors().get(id); + if (!current) return; + const remaining = field ? current.filter((error) => error.field !== field && error.field !== 'rule') : []; + const next = new Map(this.#serverErrors()); + if (remaining.length > 0) next.set(id, remaining); + else next.delete(id); + this.#serverErrors.set(next); + } +} + +function toRule(row: RuleRow): PreferredTermRule { + return { discouraged: row.discouraged, preferred: row.preferred, reason: row.reason }; +} + +function withField(touched: ReadonlySet, field: RuleField): ReadonlySet { + return touched.has(field) ? touched : new Set([...touched, field]); +} + +function statusOf(row: RuleRow): RuleRowView['status'] { + const { original } = row; + if (!original) return 'added'; + const [current] = normalizePreferredTermRules([toRule(row)]); + const same = + current?.discouraged === original.discouraged && + current.preferred === original.preferred && + current.reason === original.reason; + return same ? 'unchanged' : 'edited'; +} + +const fold = (term: string) => term.trim().toLowerCase(); + +/** + * Turns a domain error into a translatable code plus parameters. The domain's own + * messages are English and cite row numbers; the UI names the terms instead, which + * reads better next to the field and needs no row numbering. + */ +function describe(error: PreferredTermRuleError, row: RuleRow, rows: readonly RuleRow[]): RuleFieldError { + switch (error.code) { + case 'duplicate': + return { code: error.code, params: { term: row.discouraged.trim() } }; + case 'chain': { + const target = rows.find((candidate) => fold(candidate.discouraged) === fold(row.preferred)); + return { code: error.code, params: { term: row.preferred.trim(), preferred: target?.preferred.trim() ?? '' } }; + } + case 'contains-discouraged': { + const others = rows + .filter((candidate) => candidate.discouraged.trim() && candidate.preferred.trim()) + .map(toRule) + .map((rule) => ({ ...rule, discouraged: rule.discouraged.trim() })); + const [finding] = findPreferredTermFindings(row.preferred, others); + return { code: error.code, params: { term: finding?.rule.discouraged ?? row.discouraged.trim() } }; + } + case 'cycle': + return { code: error.code, params: { term: row.discouraged.trim() } }; + default: + return { code: error.code, params: {} }; + } +} diff --git a/apps/tracker/src/app/settings/settings.html b/apps/tracker/src/app/settings/settings.html index c59a033..2119949 100644 --- a/apps/tracker/src/app/settings/settings.html +++ b/apps/tracker/src/app/settings/settings.html @@ -2,13 +2,14 @@

{{ TOKENS.SETTINGS.TITLE | transloco }}

- - @if (!isEmpty() || hasChanges()) { -
+ + @if (showSaveBar()) { +

- @if (hasChanges()) { + @if (hasAnyChanges()) { edit_note - {{ TOKENS.SETTINGS.PROTECTEDTERMSUNSAVEDCHANGESX | transloco : { count: changeCount() } }} + {{ TOKENS.SETTINGS.PROTECTEDTERMSUNSAVEDCHANGESX | transloco : { count: totalChangeCount() } }} } @else { check_circle {{ TOKENS.SETTINGS.PROTECTEDTERMSALLSAVED | transloco }} @@ -16,15 +17,16 @@

{{ TOKENS.SETTINGS.TITLE | transloco }}

- @@ -75,6 +77,7 @@

autocomplete="off" spellcheck="false" [value]="addDraft()" + [disabled]="editingLocked()" (input)="onAddDraftChange($any($event.target).value)" [placeholder]="TOKENS.SETTINGS.PROTECTEDTERMSADDPLACEHOLDER | transloco" [attr.aria-label]="TOKENS.SETTINGS.PROTECTEDTERMSADDARIALABEL | transloco" @@ -82,7 +85,7 @@

[attr.aria-describedby]="addError() ? 'settings-add-error' : null" /> - @@ -223,6 +226,7 @@

mat-button type="button" class="term-undo" + [disabled]="editingLocked()" (click)="restoreTerm(entry)" [attr.aria-label]="TOKENS.SETTINGS.PROTECTEDTERMSRESTOREARIAX | transloco : { term: entry.value }" > @@ -234,6 +238,7 @@

mat-icon-button type="button" class="term-action" + [disabled]="editingLocked()" (click)="beginEdit(entry)" [attr.aria-label]="TOKENS.SETTINGS.PROTECTEDTERMSEDITARIAX | transloco : { term: entry.value }" [matTooltip]="TOKENS.COMMON.ACTIONS.EDIT | transloco" @@ -244,6 +249,7 @@

mat-icon-button type="button" class="term-action term-action--remove" + [disabled]="editingLocked()" (click)="removeTerm(entry)" [attr.aria-label]="TOKENS.SETTINGS.PROTECTEDTERMSREMOVEARIAX | transloco : { term: entry.value }" [matTooltip]="TOKENS.COMMON.ACTIONS.DELETE | transloco" @@ -262,4 +268,161 @@

} + + +
+
+

+ {{ TOKENS.SETTINGS.PREFERREDTERMINOLOGY.TITLE | transloco }} +

+ {{ TOKENS.SETTINGS.PREFERREDTERMINOLOGY.COUNTX | transloco : { count: ruleCount() } }} +
+ +
+ info +

{{ TOKENS.SETTINGS.PREFERREDTERMINOLOGY.HINT | transloco }}

+
+ + @if (preferredTerminologyFilePath(); as filePath) { +

+ description + {{ TOKENS.SETTINGS.PROTECTEDTERMSFILELABEL | transloco : { file: filePath } }} +

+ } + + @if (preferredTerminologyError(); as loadError) { + + } @else if (preferredTerminologyWarning()) { +

+ info + {{ TOKENS.SETTINGS.PREFERREDTERMINOLOGY.MISSINGFILE | transloco }} +

+ } + + @if (!terminology.isEmpty()) { +
+ + +
    + @for (view of terminology.rowViews(); track view.row.id) { +
  • +
    + + @if (view.errors.discouraged; as error) { +

    + {{ RULE_ERROR_TOKENS[error.code] | transloco : error.params }} +

    + } +
    + + + +
    + + @if (view.errors.preferred; as error) { +

    + {{ RULE_ERROR_TOKENS[error.code] | transloco : error.params }} +

    + } +
    + +
    + + @if (view.errors.reason; as error) { +

    + {{ RULE_ERROR_TOKENS[error.code] | transloco : error.params }} +

    + } +
    + + +
  • + } +
+
+ } @else if (!preferredTerminologyError()) { + +
+ spellcheck +

{{ TOKENS.SETTINGS.PREFERREDTERMINOLOGY.EMPTYTITLE | transloco }}

+

{{ TOKENS.SETTINGS.PREFERREDTERMINOLOGY.EMPTYMESSAGE | transloco }}

+
+ } + +
+ +
+

diff --git a/apps/tracker/src/app/settings/settings.scss b/apps/tracker/src/app/settings/settings.scss index bc013cb..4fa2976 100644 --- a/apps/tracker/src/app/settings/settings.scss +++ b/apps/tracker/src/app/settings/settings.scss @@ -1,6 +1,9 @@ :host { display: block; height: 100%; + // Two sections share the page, so the page scrolls; the header (and its save bar) + // stays pinned while each section's list keeps its own bounded scroller. + overflow-y: auto; } .settings-page { @@ -10,14 +13,20 @@ // The app has no global border-box reset, so the padding below would otherwise // be added on top of the full height and push the column past the viewport. box-sizing: border-box; - height: 100%; + min-height: 100%; padding: var(--spacing-6) var(--spacing-6) var(--spacing-6); max-width: 720px; margin: 0 auto; - overflow: hidden; } .settings-header { + position: sticky; + top: 0; + z-index: 2; + margin-block: calc(-1 * var(--spacing-3)); + padding-block: var(--spacing-3); + // Matches the app content surface, so the pinned header reads as part of the page. + background: var(--color-background-subtle); display: flex; align-items: center; justify-content: space-between; @@ -62,8 +71,7 @@ .terms { display: flex; flex-direction: column; - flex: 1 1 auto; - min-height: 0; + flex: none; // An opaque twin of the panel fill, so the scroll shadows below have something // solid to fade into. color-mix with `transparent` cannot be used for that. @@ -219,6 +227,7 @@ .terms-scroll { flex: 1 1 auto; min-height: 120px; + max-height: min(24rem, 50vh); overflow-y: auto; overscroll-behavior: contain; scrollbar-color: var(--color-border-strong) transparent; @@ -642,6 +651,170 @@ } } +// ── Preferred terminology ──────────────────────────────────────────────────── + +.rules { + padding-top: var(--spacing-4); + border-top: 1px solid var(--color-border-subtle); +} + +.rules-load-error { + display: flex; + align-items: flex-start; + gap: var(--spacing-2); + margin-top: var(--spacing-3); + padding: var(--spacing-3) var(--spacing-4); + border: 1px solid color-mix(in srgb, var(--color-error) 35%, transparent); + border-radius: var(--border-radius-lg); + color: color-mix(in srgb, var(--color-error) 65%, var(--color-text-primary)); + background: color-mix(in srgb, var(--color-error) 10%, transparent); + font-size: var(--font-size-sm); + line-height: var(--line-height-normal); +} + +.rules-load-error-text { + margin: 0; +} + +.rules-load-error-detail { + margin: var(--spacing-1) 0 0; + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + word-break: break-word; +} + +.rules-missing-file { + display: flex; + align-items: flex-start; + gap: var(--spacing-1); + margin: var(--spacing-2) 0 0; + color: color-mix(in srgb, var(--color-warning) 42%, var(--color-text-primary)); + font-size: var(--font-size-xs); + line-height: var(--line-height-snug); +} + +.rule { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) minmax(0, 1.2fr) auto; + align-items: start; + gap: var(--spacing-1) var(--spacing-2); + padding: var(--spacing-2) var(--spacing-2) var(--spacing-2) var(--spacing-3); + border-top: 1px solid var(--color-border-subtle); + transition: background var(--transition-fast); + + &:first-child { + border-top: 0; + } + + &:focus-within { + background: color-mix(in srgb, var(--color-primary) 5%, transparent); + } +} + +.rule[data-status='added'] { + background: color-mix(in srgb, var(--color-success) 9%, transparent); +} + +.rule[data-status='edited'] { + background: color-mix(in srgb, var(--color-warning) 10%, transparent); +} + +.rules-header { + padding-block: var(--spacing-2); + border-bottom: 1px solid var(--color-border); + background: color-mix(in srgb, var(--color-background-muted) 55%, transparent); + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + letter-spacing: 0.02em; +} + +.rules-list { + max-height: min(28rem, 60vh); + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: thin; +} + +.rule-cell { + display: flex; + flex-direction: column; + min-width: 0; +} + +.rule-input { + width: 100%; + box-sizing: border-box; + padding: var(--spacing-2) var(--spacing-3); + border: 1px solid var(--color-border-strong); + border-radius: var(--border-radius-md); + background: var(--color-background); + color: var(--color-text-primary); + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + caret-color: var(--color-primary); + + &::placeholder { + color: var(--color-text-tertiary); + } + + &:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: 1px; + border-color: var(--color-secondary); + } + + &[aria-invalid='true'] { + border-color: var(--color-error); + } +} + +.rule-input--reason { + font-family: inherit; +} + +.rule-arrow { + // Pinned to the input line, so an error growing a cell does not drag it down. + align-self: start; + margin-top: calc((var(--font-size-sm) * 1.2 + 2 * var(--spacing-2) + 2px - 16px) / 2); + font-size: 16px; + width: 16px; + height: 16px; + color: var(--color-text-tertiary); +} + +.rules-header > :nth-child(1), +.rules-header > :nth-child(3), +.rules-header > :nth-child(4) { + // Line the labels up with the text inside the inputs below them. + padding-inline-start: calc(var(--spacing-3) + 1px); +} + +.rules-header > :nth-child(2) { + width: 16px; +} + +.rules-header > :nth-child(5) { + width: 34px; +} + +.rule-error { + margin-top: var(--spacing-1); +} + +.rule-remove { + align-self: start; +} + +.rules-actions { + display: flex; + margin-top: var(--spacing-3); +} + +.rules-add .mat-icon { + margin-inline-end: var(--spacing-1); +} + // ── Short viewports ────────────────────────────────────────────────────────── // Landscape phones and short desktop windows. The section title already says what // this list is, so the explanatory hint and the file path give up their rows to @@ -674,6 +847,38 @@ padding: var(--spacing-4) var(--spacing-4) var(--spacing-5); } + // Rules stack: discouraged → preferred on one line, the reason beneath. + .rule { + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto; + grid-template-areas: + 'discouraged arrow preferred remove' + 'reason reason reason reason'; + } + + .rule > :nth-child(1) { + grid-area: discouraged; + } + + .rule > :nth-child(2) { + grid-area: arrow; + } + + .rule > :nth-child(3) { + grid-area: preferred; + } + + .rule > :nth-child(4) { + grid-area: reason; + } + + .rule > :nth-child(5) { + grid-area: remove; + } + + .rules-header > :nth-child(4) { + display: none; + } + .terms-add-button { min-width: 64px; padding-inline: var(--spacing-3); diff --git a/apps/tracker/src/app/settings/settings.spec.ts b/apps/tracker/src/app/settings/settings.spec.ts index d3fe3f9..59fa749 100644 --- a/apps/tracker/src/app/settings/settings.spec.ts +++ b/apps/tracker/src/app/settings/settings.spec.ts @@ -1,9 +1,13 @@ import { signal } from '@angular/core'; import type { ComponentFixture } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { provideTranslocoMessageformat } from '@jsverse/transloco-messageformat'; import { createComponentFactory, type Spectator } from '@ngneat/spectator/vitest'; -import type { LingoTrackerConfigDto } from '@simoncodes-ca/data-transfer'; +import type { LingoTrackerConfigDto, PreferredTermRuleErrorDto } from '@simoncodes-ca/data-transfer'; +import { icuToTransloco, validateICUSyntax } from '@simoncodes-ca/domain'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import ruleErrorEntries from '../../i18n/settings/preferredTerminology/error/resource_entries.json'; +import { TRACKER_TOKENS } from '../../i18n-types/tracker-resources'; import { getTranslocoTestingModule } from '../../testing/transloco-testing.module'; import { CollectionsStore } from '../collections/store/collections.store'; import { Settings } from './settings'; @@ -28,6 +32,7 @@ describe('Settings', () => { config: signal(config), error: signal(error), isLoading: signal(false), + configRuleErrors: signal([]), updateGlobalConfig: updateGlobalConfigMock, }); @@ -355,6 +360,7 @@ describe('Settings', () => { config, error: signal(null), isLoading: signal(false), + configRuleErrors: signal([]), updateGlobalConfig: updateGlobalConfigMock, }); @@ -372,6 +378,7 @@ describe('Settings', () => { config, error: signal(null), isLoading: signal(false), + configRuleErrors: signal([]), updateGlobalConfig: updateGlobalConfigMock, }); @@ -385,4 +392,549 @@ describe('Settings', () => { expect(component.termsToSave()).toEqual(['C++', 'iPhone', 'Node.js']); }); }); + + describe('preferred terminology', () => { + const TERMINOLOGY_PATH = '/project/.lingo-tracker-preferred-terminology.json'; + const terminologyConfig: LingoTrackerConfigDto = { + ...baseConfig, + preferredTerminology: [ + { discouraged: 'E-mail', preferred: 'email' }, + { discouraged: 'Expenditure', preferred: 'Investment', reason: 'Current planning term.' }, + ], + preferredTerminologyFilePath: TERMINOLOGY_PATH, + }; + + const host = (): HTMLElement => fixture.nativeElement; + const ruleRows = () => Array.from(host().querySelectorAll('li.rule')); + const ruleInput = (row: number, field: 'discouraged' | 'preferred' | 'reason'): HTMLInputElement => { + const input = + ruleRows()[row]?.querySelectorAll('input.rule-input')[ + ['discouraged', 'preferred', 'reason'].indexOf(field) + ]; + expect(input).toBeDefined(); + return input as HTMLInputElement; + }; + const type = (row: number, field: 'discouraged' | 'preferred' | 'reason', value: string) => { + const input = ruleInput(row, field); + input.value = value; + input.dispatchEvent(new Event('input')); + spectator.detectChanges(); + }; + const blur = (row: number, field: 'discouraged' | 'preferred' | 'reason') => { + ruleInput(row, field).dispatchEvent(new Event('blur')); + spectator.detectChanges(); + }; + const errorFor = (row: number, field: 'discouraged' | 'preferred' | 'reason') => { + const id = ruleInput(row, field).getAttribute('aria-describedby'); + return id ? (host().querySelector(`#${id}`)?.textContent?.trim() ?? null) : null; + }; + const saveButton = (): HTMLButtonElement => { + const button = host().querySelector('button.settings-save'); + expect(button).not.toBeNull(); + return button as HTMLButtonElement; + }; + const clickAdd = () => { + host().querySelector('button.rules-add')?.click(); + spectator.detectChanges(); + spectator.flushEffects(); + }; + + it('renders one row per rule with the backing file path', () => { + render(terminologyConfig); + + expect(ruleRows()).toHaveLength(2); + expect(ruleInput(0, 'discouraged').value).toBe('E-mail'); + expect(ruleInput(1, 'preferred').value).toBe('Investment'); + expect(ruleInput(1, 'reason').value).toBe('Current planning term.'); + expect(host().querySelector(`.rules [title="${TERMINOLOGY_PATH}"]`)).not.toBeNull(); + }); + + it('labels inputs per row and names the term on the remove button', () => { + render(terminologyConfig); + + expect(ruleInput(1, 'discouraged').getAttribute('aria-label')).toBe( + 'settings.preferredTerminology.discouragedAriaX', + ); + const remove = ruleRows()[1]?.querySelector('button.rule-remove'); + expect(remove?.getAttribute('aria-label')).toBe('settings.preferredTerminology.removeAriaX'); + }); + + it('shows the empty state when there are no rules', () => { + render(baseConfig); + + expect(ruleRows()).toHaveLength(0); + expect(host().textContent).toContain('settings.preferredTerminology.emptyTitle'); + }); + + it('adds a blank row and focuses its discouraged input', () => { + render(terminologyConfig); + + clickAdd(); + + expect(ruleRows()).toHaveLength(3); + expect(document.activeElement).toBe(ruleInput(2, 'discouraged')); + expect(component.terminology.changeCount()).toBe(1); + }); + + it('does not show "required" on a blank new row until a field is touched', () => { + render(terminologyConfig); + clickAdd(); + + expect(errorFor(2, 'discouraged')).toBeNull(); + expect(errorFor(2, 'preferred')).toBeNull(); + + type(2, 'discouraged', 'Cost'); + expect(errorFor(2, 'preferred')).toBeNull(); + + blur(2, 'preferred'); + expect(errorFor(2, 'preferred')).toBe('settings.preferredTerminology.error.empty'); + }); + + it('edits a rule and counts it as a change', () => { + render(terminologyConfig); + + type(1, 'preferred', 'Capital'); + + expect(component.terminology.rulesToSave()[1]).toEqual({ + discouraged: 'Expenditure', + preferred: 'Capital', + reason: 'Current planning term.', + }); + expect(component.terminology.changeCount()).toBe(1); + expect(ruleRows()[1]?.getAttribute('data-status')).toBe('edited'); + }); + + it('removes a rule', () => { + render(terminologyConfig); + + (ruleRows()[0]?.querySelector('button.rule-remove') as HTMLButtonElement).click(); + spectator.detectChanges(); + + expect(ruleRows()).toHaveLength(1); + expect(component.terminology.rulesToSave()).toEqual([ + { discouraged: 'Expenditure', preferred: 'Investment', reason: 'Current planning term.' }, + ]); + expect(component.terminology.changeCount()).toBe(1); + }); + + it('flags a duplicate discouraged term inline', () => { + render(terminologyConfig); + clickAdd(); + + type(2, 'preferred', 'Spend'); + type(2, 'discouraged', 'expenditure'); + + expect(errorFor(2, 'discouraged')).toBe('settings.preferredTerminology.error.duplicateX'); + expect(ruleInput(2, 'discouraged').getAttribute('aria-invalid')).toBe('true'); + }); + + it('flags a chain on the preferred term', () => { + render(terminologyConfig); + clickAdd(); + + type(2, 'discouraged', 'Spend'); + type(2, 'preferred', 'Expenditure'); + + expect(errorFor(2, 'preferred')).toBe('settings.preferredTerminology.error.chainX'); + }); + + it('flags a preferred term containing a discouraged term', () => { + render(terminologyConfig); + + type(1, 'preferred', 'Capital expenditure'); + + expect(errorFor(1, 'preferred')).toBe('settings.preferredTerminology.error.containsDiscouragedX'); + }); + + it('flags an existing rule when a new row turns it into a chain', () => { + render(terminologyConfig); + clickAdd(); + + type(2, 'discouraged', 'Investment'); + type(2, 'preferred', 'Capital'); + + expect(errorFor(1, 'preferred')).toBe('settings.preferredTerminology.error.chainX'); + }); + + it('describes errors with the terms involved', () => { + render(terminologyConfig); + clickAdd(); + type(2, 'discouraged', 'Spend'); + type(2, 'preferred', 'Expenditure'); + + expect(component.terminology.rowViews()[2]?.errors.preferred).toEqual({ + code: 'chain', + params: { term: 'Expenditure', preferred: 'Investment' }, + }); + }); + + it('keeps Save disabled until something changes', () => { + render(terminologyConfig); + + expect(saveButton().disabled).toBe(true); + + type(0, 'reason', 'House style.'); + + expect(saveButton().disabled).toBe(false); + }); + + it('disables Save while an error is showing', () => { + render(terminologyConfig); + + type(1, 'preferred', 'expenditure'); + + expect(saveButton().disabled).toBe(true); + }); + + it('reveals hidden errors instead of saving a blank new row', () => { + render(terminologyConfig); + clickAdd(); + expect(saveButton().disabled).toBe(false); + + saveButton().click(); + spectator.detectChanges(); + spectator.flushEffects(); + + expect(updateGlobalConfigMock).not.toHaveBeenCalled(); + expect(errorFor(2, 'discouraged')).toBe('settings.preferredTerminology.error.empty'); + expect(errorFor(2, 'preferred')).toBe('settings.preferredTerminology.error.empty'); + expect(saveButton().disabled).toBe(true); + expect(document.activeElement).toBe(ruleInput(2, 'discouraged')); + }); + + it('saves the normalized list and leaves protected terms out when they did not change', () => { + render(terminologyConfig); + clickAdd(); + type(2, 'discouraged', ' Cost '); + type(2, 'preferred', 'Price'); + type(2, 'reason', ' '); + + saveButton().click(); + + expect(updateGlobalConfigMock).toHaveBeenCalledWith({ + preferredTerminology: [ + { discouraged: 'E-mail', preferred: 'email' }, + { discouraged: 'Expenditure', preferred: 'Investment', reason: 'Current planning term.' }, + { discouraged: 'Cost', preferred: 'Price' }, + ], + }); + }); + + it('sends both lists in one request when both changed', () => { + render(terminologyConfig); + component.onAddDraftChange('C++'); + component.addTerm(); + type(0, 'preferred', 'Email'); + + component.save(); + + expect(updateGlobalConfigMock).toHaveBeenCalledWith({ + protectedTerms: ['C++', 'iPhone', 'Node.js'], + preferredTerminology: [ + { discouraged: 'E-mail', preferred: 'Email' }, + { discouraged: 'Expenditure', preferred: 'Investment', reason: 'Current planning term.' }, + ], + }); + }); + + it('re-seeds rows from the reloaded config after a save, in the server order', () => { + const config = signal(terminologyConfig); + renderStore({ + config, + error: signal(null), + isLoading: signal(false), + configRuleErrors: signal([]), + updateGlobalConfig: updateGlobalConfigMock, + }); + clickAdd(); + type(2, 'discouraged', 'Cost'); + type(2, 'preferred', 'Price'); + + component.save(); + config.set({ + ...terminologyConfig, + preferredTerminology: [ + { discouraged: 'Cost', preferred: 'Price' }, + ...(terminologyConfig.preferredTerminology ?? []), + ], + }); + spectator.detectChanges(); + spectator.flushEffects(); + spectator.detectChanges(); + + expect(ruleRows().map((row) => row.querySelector('input')?.value)).toEqual([ + 'Cost', + 'E-mail', + 'Expenditure', + ]); + expect(component.terminology.hasChanges()).toBe(false); + }); + + it('maps server errors onto the submitted rows and clears one when its field is edited', () => { + const configRuleErrors = signal([]); + const error = signal(null); + renderStore({ + config: signal(terminologyConfig), + error, + isLoading: signal(false), + configRuleErrors, + updateGlobalConfig: updateGlobalConfigMock, + }); + type(1, 'reason', 'Changed.'); + component.save(); + + error.set('server says no'); + configRuleErrors.set([{ index: 1, field: 'preferred', code: 'self-mapping', message: 'server says no' }]); + spectator.flushEffects(); + spectator.detectChanges(); + + expect(errorFor(1, 'preferred')).toBe('settings.preferredTerminology.error.selfMapping'); + expect(errorFor(0, 'preferred')).toBeNull(); + + type(1, 'preferred', 'Capital'); + + expect(errorFor(1, 'preferred')).toBeNull(); + }); + + it('shows a banner when the terminology file failed to load, and still allows saving a fix', () => { + render({ + ...baseConfig, + preferredTerminologyFilePath: TERMINOLOGY_PATH, + preferredTerminologyError: 'Preferred terminology file is not valid JSON', + }); + + const banner = host().querySelector('.rules-load-error'); + expect(banner?.getAttribute('role')).toBe('alert'); + expect(banner?.textContent).toContain('settings.preferredTerminology.loadError'); + expect(banner?.textContent).toContain('Preferred terminology file is not valid JSON'); + expect(host().textContent).not.toContain('settings.preferredTerminology.emptyTitle'); + + clickAdd(); + type(0, 'discouraged', 'Cost'); + type(0, 'preferred', 'Price'); + saveButton().click(); + + expect(updateGlobalConfigMock).toHaveBeenCalledWith({ + preferredTerminology: [{ discouraged: 'Cost', preferred: 'Price' }], + }); + }); + + it('does not rewrite a broken terminology file when only protected terms are saved', () => { + render({ ...baseConfig, preferredTerminologyError: 'broken' }); + component.onAddDraftChange('C++'); + component.addTerm(); + + component.save(); + + expect(updateGlobalConfigMock).toHaveBeenCalledWith({ protectedTerms: ['C++', 'iPhone', 'Node.js'] }); + }); + + it('notes a missing explicit file without an error banner', () => { + render({ ...baseConfig, preferredTerminologyWarning: 'Preferred terminology file not found' }); + + expect(host().querySelector('.rules-load-error')).toBeNull(); + expect(host().textContent).toContain('settings.preferredTerminology.missingFile'); + }); + + it('reverts terminology edits with Revert all', () => { + render(terminologyConfig); + type(0, 'preferred', 'Email'); + clickAdd(); + + component.revertAll(); + spectator.detectChanges(); + + expect(ruleRows()).toHaveLength(2); + expect(ruleInput(0, 'preferred').value).toBe('email'); + expect(component.hasAnyChanges()).toBe(false); + }); + + describe('editing lock', () => { + const addRuleButton = (): HTMLButtonElement => { + const button = host().querySelector('button.rules-add'); + expect(button).not.toBeNull(); + return button as HTMLButtonElement; + }; + const protectedAddInput = (): HTMLInputElement => { + const input = host().querySelector('.terms-add input'); + expect(input).not.toBeNull(); + return input as HTMLInputElement; + }; + /** Every control that changes either list: rule inputs, rule and term buttons, the term add field. */ + const editControls = () => + Array.from( + host().querySelectorAll( + 'input.rule-input, button.rule-remove, button.rules-add, .terms-add input, .term-action, .term-undo', + ), + ); + const buildPendingStore = () => ({ + config: signal(terminologyConfig), + error: signal(null), + isLoading: signal(false), + configRuleErrors: signal([]), + updateGlobalConfig: updateGlobalConfigMock, + }); + const settle = () => { + spectator.detectChanges(); + spectator.flushEffects(); + spectator.detectChanges(); + }; + + it('keeps both lists read-only until the config has loaded', () => { + const store = { ...buildPendingStore(), config: signal(null) }; + renderStore(store); + + expect(component.editingLocked()).toBe(true); + expect(addRuleButton().disabled).toBe(true); + expect(protectedAddInput().disabled).toBe(true); + + store.config.set(terminologyConfig); + settle(); + + expect(component.editingLocked()).toBe(false); + expect(addRuleButton().disabled).toBe(false); + expect(editControls().every((control) => !control.disabled)).toBe(true); + }); + + it('does not let a rule be added before the config seeds the list', () => { + const store = { ...buildPendingStore(), config: signal(null) }; + renderStore(store); + + clickAdd(); + + expect(ruleRows()).toHaveLength(0); + expect(component.terminology.isEmpty()).toBe(true); + + store.config.set(terminologyConfig); + settle(); + + expect(ruleRows()).toHaveLength(2); + expect(component.terminology.hasChanges()).toBe(false); + }); + + it('locks every edit control while a save is in flight and unlocks once the reloaded config arrives', () => { + const store = buildPendingStore(); + renderStore(store); + type(0, 'preferred', 'Email'); + + saveButton().click(); + spectator.detectChanges(); + + expect(updateGlobalConfigMock).toHaveBeenCalled(); + expect(editControls().length).toBeGreaterThan(0); + expect(editControls().every((control) => control.disabled)).toBe(true); + expect(saveButton().disabled).toBe(true); + + // A rule cannot be started that the post-save reseed would wipe. + clickAdd(); + expect(ruleRows()).toHaveLength(2); + + // The store clears its loading flag once the write lands, before the refetch: still locked. + store.isLoading.set(true); + spectator.detectChanges(); + store.isLoading.set(false); + spectator.detectChanges(); + expect(addRuleButton().disabled).toBe(true); + + store.config.set({ + ...terminologyConfig, + preferredTerminology: [ + { discouraged: 'E-mail', preferred: 'Email' }, + { discouraged: 'Expenditure', preferred: 'Investment', reason: 'Current planning term.' }, + ], + }); + settle(); + + expect(editControls().every((control) => !control.disabled)).toBe(true); + expect(ruleInput(0, 'preferred').value).toBe('Email'); + }); + + it('unlocks after a failed save and keeps the unsaved edits', () => { + const store = buildPendingStore(); + renderStore(store); + type(0, 'preferred', 'Email'); + + saveButton().click(); + spectator.detectChanges(); + expect(addRuleButton().disabled).toBe(true); + + store.error.set('update failed'); + settle(); + + expect(editControls().every((control) => !control.disabled)).toBe(true); + expect(ruleInput(0, 'preferred').value).toBe('Email'); + expect(component.terminology.changeCount()).toBe(1); + }); + + it('keeps an edit made once a save has completed when a later config refetch arrives', () => { + const store = buildPendingStore(); + renderStore(store); + type(0, 'preferred', 'Email'); + saveButton().click(); + const saved = { + ...terminologyConfig, + preferredTerminology: [ + { discouraged: 'E-mail', preferred: 'Email' }, + { discouraged: 'Expenditure', preferred: 'Investment', reason: 'Current planning term.' }, + ], + }; + store.config.set(saved); + settle(); + + clickAdd(); + type(2, 'discouraged', 'Cost'); + type(2, 'preferred', 'Price'); + store.config.set({ ...saved }); + settle(); + + expect(ruleRows()).toHaveLength(3); + expect(component.terminology.rulesToSave()[2]).toEqual({ discouraged: 'Cost', preferred: 'Price' }); + }); + }); + + describe('rule error messages', () => { + /** The app renders through the messageformat transpiler, so every stored value must be valid ICU. */ + it('stores every rule error as valid ICU in every locale', () => { + for (const [key, entry] of Object.entries(ruleErrorEntries)) { + for (const [field, value] of Object.entries(entry)) { + if (field === 'comment' || field === 'tags') continue; + expect(validateICUSyntax(value as string), `${key} (${field}): ${value}`).toBe(true); + } + } + }); + + const createWithMessageformat = createComponentFactory({ + component: Settings, + imports: [ + NoopAnimationsModule, + getTranslocoTestingModule({ + langs: { + en: { + // The bundled form, as `lingo-tracker bundle` writes it. + [TRACKER_TOKENS.SETTINGS.PREFERREDTERMINOLOGY.ERROR.INVALIDCHARACTER]: icuToTransloco( + ruleErrorEntries.invalidCharacter.source, + ), + }, + }, + }), + ], + providers: [provideTranslocoMessageformat()], + detectChanges: false, + }); + + it('renders the invalid-character error with its literal braces', () => { + spectator = createWithMessageformat({ + providers: [{ provide: CollectionsStore, useValue: buildStore(terminologyConfig) }], + }); + fixture = spectator.fixture; + component = spectator.component; + spectator.detectChanges(); + spectator.flushEffects(); + + type(0, 'discouraged', 'E-{mail}'); + + expect(errorFor(0, 'discouraged')).toBe('Cannot contain { } < or >'); + }); + }); + }); }); diff --git a/apps/tracker/src/app/settings/settings.ts b/apps/tracker/src/app/settings/settings.ts index e7fb272..6920a9b 100644 --- a/apps/tracker/src/app/settings/settings.ts +++ b/apps/tracker/src/app/settings/settings.ts @@ -1,25 +1,26 @@ +import { CommonModule } from '@angular/common'; import { - Component, ChangeDetectionStrategy, + Component, + computed, type ElementRef, - inject, effect, + inject, signal, - computed, untracked, viewChildren, } from '@angular/core'; -import { CommonModule } from '@angular/common'; import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { TranslocoModule } from '@jsverse/transloco'; import { normalizeProtectedTerms } from '@simoncodes-ca/domain'; -import { CollectionsStore } from '../collections/store/collections.store'; import { TRACKER_TOKENS } from '../../i18n-types/tracker-resources'; +import { CollectionsStore } from '../collections/store/collections.store'; +import { PreferredTerminologyDraft, type RuleField, type RuleFieldError } from './preferred-terminology-draft'; /** * One row of the protected-terms editor. `original` is the value as last saved — @@ -38,13 +39,27 @@ const FILTER_THRESHOLD = 8; const compareTerms = (a: string, b: string): number => a.localeCompare(b, undefined, { sensitivity: 'base' }); +/** Translation token for each rule error code. */ +const RULE_ERROR_TOKENS: Record = { + empty: TRACKER_TOKENS.SETTINGS.PREFERREDTERMINOLOGY.ERROR.EMPTY, + 'invalid-character': TRACKER_TOKENS.SETTINGS.PREFERREDTERMINOLOGY.ERROR.INVALIDCHARACTER, + 'self-mapping': TRACKER_TOKENS.SETTINGS.PREFERREDTERMINOLOGY.ERROR.SELFMAPPING, + duplicate: TRACKER_TOKENS.SETTINGS.PREFERREDTERMINOLOGY.ERROR.DUPLICATEX, + chain: TRACKER_TOKENS.SETTINGS.PREFERREDTERMINOLOGY.ERROR.CHAINX, + 'contains-discouraged': TRACKER_TOKENS.SETTINGS.PREFERREDTERMINOLOGY.ERROR.CONTAINSDISCOURAGEDX, + cycle: TRACKER_TOKENS.SETTINGS.PREFERREDTERMINOLOGY.ERROR.CYCLEX, + 'invalid-type': TRACKER_TOKENS.SETTINGS.PREFERREDTERMINOLOGY.ERROR.INVALIDTYPE, +}; + /** - * Settings view. Currently exposes the global protected-terms list, written - * through `PUT /api/config`. Structured so other global fields can be added - * later without rework. Never exposes collections/locales/baseLocale editing. + * Settings view. Exposes the global protected-terms list and the preferred-terminology + * rules, both written through `PUT /api/config`. Never exposes + * collections/locales/baseLocale editing. * * Edits are staged: adds, renames and removals are held as pending row state and - * only reach the API on Save, so every change is visible and reversible first. + * only reach the API on Save, so every change is visible and reversible first. One + * save bar covers both lists; a save sends only the lists that changed, so saving + * protected terms never rewrites a terminology file that failed to load. */ @Component({ selector: 'app-settings', @@ -71,6 +86,18 @@ export class Settings { private readonly editInputs = viewChildren>('editInput'); private readonly termRows = viewChildren>('termRow'); + private readonly ruleInputs = viewChildren>('ruleInput'); + + /** Staged preferred-terminology edits. */ + readonly terminology = new PreferredTerminologyDraft(); + readonly RULE_ERROR_TOKENS = RULE_ERROR_TOKENS; + readonly preferredTerminologyFilePath = computed(() => this.store.config()?.preferredTerminologyFilePath); + readonly preferredTerminologyError = computed(() => this.store.config()?.preferredTerminologyError); + readonly preferredTerminologyWarning = computed(() => this.store.config()?.preferredTerminologyWarning); + /** Rules with a discouraged term; a blank row just added is not a rule yet. */ + readonly ruleCount = computed(() => this.terminology.rulesToSave().filter((rule) => rule.discouraged).length); + /** Row whose discouraged input takes focus once rendered — a newly added rule, or the first invalid one. */ + readonly #focusRuleInput = signal(null); readonly entries = signal([]); readonly addDraft = signal(''); @@ -88,6 +115,13 @@ export class Settings { readonly #seeded = signal(false); /** Set while a save is in flight so the next config arrival is treated as the new baseline. */ readonly #awaitingSave = signal(false); + /** + * Locks both editors until the first config seeds them, and while a save is in flight: each + * of those config arrivals reseeds both lists, so an edit made in either window would be lost. + * The save latch marks the save, not `store.isLoading()`, which clears once the write lands — + * before the refetch that reseeds arrives. + */ + readonly editingLocked = computed(() => !this.#seeded() || this.#awaitingSave()); #nextId = 0; /** Row to reveal once it has rendered, so an added term is never added off-screen. */ readonly #scrollToId = signal(null); @@ -122,6 +156,16 @@ export class Settings { readonly hasChanges = computed(() => this.changeCount() > 0); readonly isEmpty = computed(() => this.entries().length === 0); + + /** Changes across both lists, for the page save bar. */ + readonly totalChangeCount = computed(() => this.changeCount() + this.terminology.changeCount()); + readonly hasAnyChanges = computed(() => this.totalChangeCount() > 0); + readonly showSaveBar = computed(() => !this.isEmpty() || !this.terminology.isEmpty() || this.hasAnyChanges()); + /** Save stays enabled while terminology errors are still hidden, so clicking it can reveal them. */ + readonly canSave = computed( + () => + this.hasAnyChanges() && !this.store.isLoading() && !this.editingLocked() && !this.terminology.hasVisibleErrors(), + ); readonly showFilter = computed(() => this.entries().length > FILTER_THRESHOLD); readonly isFiltering = computed(() => this.filter().trim().length > 0); readonly hasNoMatches = computed(() => !this.isEmpty() && this.isFiltering() && this.visibleEntries().length === 0); @@ -136,10 +180,28 @@ export class Settings { const config = this.store.config(); if (!config) return; untracked(() => { - if (!this.#seeded() || this.#awaitingSave()) this.#seed(config.protectedTerms ?? []); + if (!this.#seeded() || this.#awaitingSave()) { + this.#seed(config.protectedTerms ?? []); + this.terminology.seed(config.preferredTerminology ?? []); + } }); }); + // A save rejected with per-row errors maps them back onto the rows that were sent. + effect(() => { + const errors = this.store.configRuleErrors(); + if (errors.length === 0) return; + untracked(() => this.terminology.applyServerErrors(errors)); + }); + + effect(() => { + const target = this.#focusRuleInput(); + const input = this.ruleInputs().find((ref) => ref.nativeElement.id === target); + if (target === null || !input) return; + input.nativeElement.focus(); + this.#focusRuleInput.set(null); + }); + // A failed save never refetches, so release the save latch on the error instead. effect(() => { if (!this.store.error()) return; @@ -287,19 +349,60 @@ export class Settings { revertAll(): void { this.#seed(this.store.config()?.protectedTerms ?? []); + this.terminology.revert(); this.filter.set(''); } + /** DOM id of a rule input, shared by its label wiring, its error and focus requests. */ + ruleInputId(rowId: number, field: RuleField): string { + return `settings-rule-${rowId}-${field}`; + } + + ruleErrorId(rowId: number, field: RuleField): string { + return `${this.ruleInputId(rowId, field)}-error`; + } + + addRule(): void { + const id = this.terminology.addRow(); + this.#focusRuleInput.set(this.ruleInputId(id, 'discouraged')); + } + + onRuleInput(rowId: number, field: RuleField, value: string): void { + this.terminology.updateField(rowId, field, value); + } + clearFilter(): void { this.filter.set(''); } + /** + * Sends every changed list in one request. Invalid terminology blocks the whole save — + * nothing is half-applied — and reveals errors still hidden on untouched fields. + */ save(): void { - if (!this.hasChanges()) return; + if (!this.hasAnyChanges()) return; + if (this.terminology.hasChanges() && this.terminology.hasErrors()) { + this.terminology.revealErrors(); + this.#focusFirstInvalidRule(); + return; + } this.cancelEdit(); this.#awaitingSave.set(true); // Errors from a failed save surface via the store error signal (rendered in the template). - this.store.updateGlobalConfig({ protectedTerms: this.termsToSave() }); + this.store.updateGlobalConfig({ + ...(this.hasChanges() && { protectedTerms: this.termsToSave() }), + ...(this.terminology.hasChanges() && { preferredTerminology: this.terminology.beginSave() }), + }); + } + + #focusFirstInvalidRule(): void { + for (const view of this.terminology.rowViews()) { + const field = (['discouraged', 'preferred', 'reason'] as const).find((candidate) => view.errors[candidate]); + if (field) { + this.#focusRuleInput.set(this.ruleInputId(view.row.id, field)); + return; + } + } } #patch(id: number, patch: Partial>): void { diff --git a/apps/tracker/src/i18n/browser/translationEditor/preferredTerm/resource_entries.json b/apps/tracker/src/i18n/browser/translationEditor/preferredTerm/resource_entries.json new file mode 100644 index 0000000..caf7941 --- /dev/null +++ b/apps/tracker/src/i18n/browser/translationEditor/preferredTerm/resource_entries.json @@ -0,0 +1,22 @@ +{ + "messageX": { + "source": "Preferred terminology: consider “{preferred}” instead of “{discouraged}”.", + "comment": "Advisory under the base-locale value in the resource editor. {{ preferred }} is the configured preferred term, {{ discouraged }} the discouraged term found in the value. Keep the curly quotes around the terms.", + "tags": ["browser"], + "es": "Preferred terminology: consider “{preferred}” instead of “{discouraged}”.", + "fr-ca": "Preferred terminology: consider “{preferred}” instead of “{discouraged}”.", + "ru": "Preferred terminology: consider “{preferred}” instead of “{discouraged}”.", + "ja": "Preferred terminology: consider “{preferred}” instead of “{discouraged}”.", + "de": "Preferred terminology: consider “{preferred}” instead of “{discouraged}”." + }, + "useX": { + "source": "Use “{preferred}”", + "comment": "Button in the preferred-terminology advisory that replaces the discouraged term in the base value with {{ preferred }}. Does not save.", + "tags": ["browser"], + "es": "Use “{preferred}”", + "fr-ca": "Use “{preferred}”", + "ru": "Use “{preferred}”", + "ja": "Use “{preferred}”", + "de": "Use “{preferred}”" + } +} diff --git a/apps/tracker/src/i18n/browser/translationEditor/preferredTerm/tracker_meta.json b/apps/tracker/src/i18n/browser/translationEditor/preferredTerm/tracker_meta.json new file mode 100644 index 0000000..1c77c93 --- /dev/null +++ b/apps/tracker/src/i18n/browser/translationEditor/preferredTerm/tracker_meta.json @@ -0,0 +1,62 @@ +{ + "messageX": { + "en": { + "checksum": "b30fb379202e725826c9ce2ae310b5b3" + }, + "es": { + "checksum": "b30fb379202e725826c9ce2ae310b5b3", + "baseChecksum": "b30fb379202e725826c9ce2ae310b5b3", + "status": "new" + }, + "fr-ca": { + "checksum": "b30fb379202e725826c9ce2ae310b5b3", + "baseChecksum": "b30fb379202e725826c9ce2ae310b5b3", + "status": "new" + }, + "ru": { + "checksum": "b30fb379202e725826c9ce2ae310b5b3", + "baseChecksum": "b30fb379202e725826c9ce2ae310b5b3", + "status": "new" + }, + "ja": { + "checksum": "b30fb379202e725826c9ce2ae310b5b3", + "baseChecksum": "b30fb379202e725826c9ce2ae310b5b3", + "status": "new" + }, + "de": { + "checksum": "b30fb379202e725826c9ce2ae310b5b3", + "baseChecksum": "b30fb379202e725826c9ce2ae310b5b3", + "status": "new" + } + }, + "useX": { + "en": { + "checksum": "8c36724b30304c5ac5779488d6712554" + }, + "es": { + "checksum": "8c36724b30304c5ac5779488d6712554", + "baseChecksum": "8c36724b30304c5ac5779488d6712554", + "status": "new" + }, + "fr-ca": { + "checksum": "8c36724b30304c5ac5779488d6712554", + "baseChecksum": "8c36724b30304c5ac5779488d6712554", + "status": "new" + }, + "ru": { + "checksum": "8c36724b30304c5ac5779488d6712554", + "baseChecksum": "8c36724b30304c5ac5779488d6712554", + "status": "new" + }, + "ja": { + "checksum": "8c36724b30304c5ac5779488d6712554", + "baseChecksum": "8c36724b30304c5ac5779488d6712554", + "status": "new" + }, + "de": { + "checksum": "8c36724b30304c5ac5779488d6712554", + "baseChecksum": "8c36724b30304c5ac5779488d6712554", + "status": "new" + } + } +} diff --git a/apps/tracker/src/i18n/settings/preferredTerminology/error/resource_entries.json b/apps/tracker/src/i18n/settings/preferredTerminology/error/resource_entries.json new file mode 100644 index 0000000..2fc6944 --- /dev/null +++ b/apps/tracker/src/i18n/settings/preferredTerminology/error/resource_entries.json @@ -0,0 +1,82 @@ +{ + "empty": { + "source": "Required", + "comment": "Inline error under an empty discouraged or preferred term input", + "tags": ["settings"], + "es": "Required", + "fr-ca": "Required", + "ru": "Required", + "ja": "Required", + "de": "Required" + }, + "selfMapping": { + "source": "Must differ from the discouraged term", + "comment": "Inline error when the preferred term equals its own discouraged term (case-insensitive)", + "tags": ["settings"], + "es": "Must differ from the discouraged term", + "fr-ca": "Must differ from the discouraged term", + "ru": "Must differ from the discouraged term", + "ja": "Must differ from the discouraged term", + "de": "Must differ from the discouraged term" + }, + "duplicateX": { + "source": "“{term}” is already listed", + "comment": "Inline error when the discouraged term {{ term }} appears in an earlier rule", + "tags": ["settings"], + "es": "“{term}” is already listed", + "fr-ca": "“{term}” is already listed", + "ru": "“{term}” is already listed", + "ja": "“{term}” is already listed", + "de": "“{term}” is already listed" + }, + "chainX": { + "source": "“{term}” is itself discouraged; use “{preferred}” instead", + "comment": "Inline error when the preferred term {{ term }} is another rule's discouraged term, whose preferred term is {{ preferred }}", + "tags": ["settings"], + "es": "“{term}” is itself discouraged; use “{preferred}” instead", + "fr-ca": "“{term}” is itself discouraged; use “{preferred}” instead", + "ru": "“{term}” is itself discouraged; use “{preferred}” instead", + "ja": "“{term}” is itself discouraged; use “{preferred}” instead", + "de": "“{term}” is itself discouraged; use “{preferred}” instead" + }, + "containsDiscouragedX": { + "source": "Contains the discouraged term “{term}”", + "comment": "Inline error when the preferred term contains the discouraged term {{ term }} as a whole word", + "tags": ["settings"], + "es": "Contains the discouraged term “{term}”", + "fr-ca": "Contains the discouraged term “{term}”", + "ru": "Contains the discouraged term “{term}”", + "ja": "Contains the discouraged term “{term}”", + "de": "Contains the discouraged term “{term}”" + }, + "cycleX": { + "source": "Leads back to “{term}” in a cycle", + "comment": "Inline error when following preferred terms from this rule returns to its discouraged term {{ term }}", + "tags": ["settings"], + "es": "Leads back to “{term}” in a cycle", + "fr-ca": "Leads back to “{term}” in a cycle", + "ru": "Leads back to “{term}” in a cycle", + "ja": "Leads back to “{term}” in a cycle", + "de": "Leads back to “{term}” in a cycle" + }, + "invalidType": { + "source": "Must be text", + "comment": "Inline error when a rule field is not a string (only reachable through server validation)", + "tags": ["settings"], + "es": "Must be text", + "fr-ca": "Must be text", + "ru": "Must be text", + "ja": "Must be text", + "de": "Must be text" + }, + "invalidCharacter": { + "source": "Cannot contain '{' '}' < or >", + "comment": "Inline error when a term contains ICU or HTML syntax characters", + "tags": ["settings"], + "es": "Cannot contain '{' '}' < or >", + "fr-ca": "Cannot contain '{' '}' < or >", + "ru": "Cannot contain '{' '}' < or >", + "ja": "Cannot contain '{' '}' < or >", + "de": "Cannot contain '{' '}' < or >" + } +} diff --git a/apps/tracker/src/i18n/settings/preferredTerminology/error/tracker_meta.json b/apps/tracker/src/i18n/settings/preferredTerminology/error/tracker_meta.json new file mode 100644 index 0000000..60fb2e3 --- /dev/null +++ b/apps/tracker/src/i18n/settings/preferredTerminology/error/tracker_meta.json @@ -0,0 +1,242 @@ +{ + "empty": { + "en": { + "checksum": "b651efdb98a5d6bd2b3935d0c3f4a5e2" + }, + "es": { + "checksum": "b651efdb98a5d6bd2b3935d0c3f4a5e2", + "baseChecksum": "b651efdb98a5d6bd2b3935d0c3f4a5e2", + "status": "new" + }, + "fr-ca": { + "checksum": "b651efdb98a5d6bd2b3935d0c3f4a5e2", + "baseChecksum": "b651efdb98a5d6bd2b3935d0c3f4a5e2", + "status": "new" + }, + "ru": { + "checksum": "b651efdb98a5d6bd2b3935d0c3f4a5e2", + "baseChecksum": "b651efdb98a5d6bd2b3935d0c3f4a5e2", + "status": "new" + }, + "ja": { + "checksum": "b651efdb98a5d6bd2b3935d0c3f4a5e2", + "baseChecksum": "b651efdb98a5d6bd2b3935d0c3f4a5e2", + "status": "new" + }, + "de": { + "checksum": "b651efdb98a5d6bd2b3935d0c3f4a5e2", + "baseChecksum": "b651efdb98a5d6bd2b3935d0c3f4a5e2", + "status": "new" + } + }, + "selfMapping": { + "en": { + "checksum": "9de9f90b231bb1669f774cce8c22f841" + }, + "es": { + "checksum": "9de9f90b231bb1669f774cce8c22f841", + "baseChecksum": "9de9f90b231bb1669f774cce8c22f841", + "status": "new" + }, + "fr-ca": { + "checksum": "9de9f90b231bb1669f774cce8c22f841", + "baseChecksum": "9de9f90b231bb1669f774cce8c22f841", + "status": "new" + }, + "ru": { + "checksum": "9de9f90b231bb1669f774cce8c22f841", + "baseChecksum": "9de9f90b231bb1669f774cce8c22f841", + "status": "new" + }, + "ja": { + "checksum": "9de9f90b231bb1669f774cce8c22f841", + "baseChecksum": "9de9f90b231bb1669f774cce8c22f841", + "status": "new" + }, + "de": { + "checksum": "9de9f90b231bb1669f774cce8c22f841", + "baseChecksum": "9de9f90b231bb1669f774cce8c22f841", + "status": "new" + } + }, + "duplicateX": { + "en": { + "checksum": "ff535234e1eb989d10d11b34e5184dec" + }, + "es": { + "checksum": "ff535234e1eb989d10d11b34e5184dec", + "baseChecksum": "ff535234e1eb989d10d11b34e5184dec", + "status": "new" + }, + "fr-ca": { + "checksum": "ff535234e1eb989d10d11b34e5184dec", + "baseChecksum": "ff535234e1eb989d10d11b34e5184dec", + "status": "new" + }, + "ru": { + "checksum": "ff535234e1eb989d10d11b34e5184dec", + "baseChecksum": "ff535234e1eb989d10d11b34e5184dec", + "status": "new" + }, + "ja": { + "checksum": "ff535234e1eb989d10d11b34e5184dec", + "baseChecksum": "ff535234e1eb989d10d11b34e5184dec", + "status": "new" + }, + "de": { + "checksum": "ff535234e1eb989d10d11b34e5184dec", + "baseChecksum": "ff535234e1eb989d10d11b34e5184dec", + "status": "new" + } + }, + "chainX": { + "en": { + "checksum": "6015e2a434f0a723cb200817a974c465" + }, + "es": { + "checksum": "6015e2a434f0a723cb200817a974c465", + "baseChecksum": "6015e2a434f0a723cb200817a974c465", + "status": "new" + }, + "fr-ca": { + "checksum": "6015e2a434f0a723cb200817a974c465", + "baseChecksum": "6015e2a434f0a723cb200817a974c465", + "status": "new" + }, + "ru": { + "checksum": "6015e2a434f0a723cb200817a974c465", + "baseChecksum": "6015e2a434f0a723cb200817a974c465", + "status": "new" + }, + "ja": { + "checksum": "6015e2a434f0a723cb200817a974c465", + "baseChecksum": "6015e2a434f0a723cb200817a974c465", + "status": "new" + }, + "de": { + "checksum": "6015e2a434f0a723cb200817a974c465", + "baseChecksum": "6015e2a434f0a723cb200817a974c465", + "status": "new" + } + }, + "containsDiscouragedX": { + "en": { + "checksum": "c39de5fed1ede9330388ffc84d97bd36" + }, + "es": { + "checksum": "c39de5fed1ede9330388ffc84d97bd36", + "baseChecksum": "c39de5fed1ede9330388ffc84d97bd36", + "status": "new" + }, + "fr-ca": { + "checksum": "c39de5fed1ede9330388ffc84d97bd36", + "baseChecksum": "c39de5fed1ede9330388ffc84d97bd36", + "status": "new" + }, + "ru": { + "checksum": "c39de5fed1ede9330388ffc84d97bd36", + "baseChecksum": "c39de5fed1ede9330388ffc84d97bd36", + "status": "new" + }, + "ja": { + "checksum": "c39de5fed1ede9330388ffc84d97bd36", + "baseChecksum": "c39de5fed1ede9330388ffc84d97bd36", + "status": "new" + }, + "de": { + "checksum": "c39de5fed1ede9330388ffc84d97bd36", + "baseChecksum": "c39de5fed1ede9330388ffc84d97bd36", + "status": "new" + } + }, + "cycleX": { + "en": { + "checksum": "ed5a27e5491074cfb5207a8c67e0093e" + }, + "es": { + "checksum": "ed5a27e5491074cfb5207a8c67e0093e", + "baseChecksum": "ed5a27e5491074cfb5207a8c67e0093e", + "status": "new" + }, + "fr-ca": { + "checksum": "ed5a27e5491074cfb5207a8c67e0093e", + "baseChecksum": "ed5a27e5491074cfb5207a8c67e0093e", + "status": "new" + }, + "ru": { + "checksum": "ed5a27e5491074cfb5207a8c67e0093e", + "baseChecksum": "ed5a27e5491074cfb5207a8c67e0093e", + "status": "new" + }, + "ja": { + "checksum": "ed5a27e5491074cfb5207a8c67e0093e", + "baseChecksum": "ed5a27e5491074cfb5207a8c67e0093e", + "status": "new" + }, + "de": { + "checksum": "ed5a27e5491074cfb5207a8c67e0093e", + "baseChecksum": "ed5a27e5491074cfb5207a8c67e0093e", + "status": "new" + } + }, + "invalidType": { + "en": { + "checksum": "598cba41c17504cef57c0a1224d47c94" + }, + "es": { + "checksum": "598cba41c17504cef57c0a1224d47c94", + "baseChecksum": "598cba41c17504cef57c0a1224d47c94", + "status": "new" + }, + "fr-ca": { + "checksum": "598cba41c17504cef57c0a1224d47c94", + "baseChecksum": "598cba41c17504cef57c0a1224d47c94", + "status": "new" + }, + "ru": { + "checksum": "598cba41c17504cef57c0a1224d47c94", + "baseChecksum": "598cba41c17504cef57c0a1224d47c94", + "status": "new" + }, + "ja": { + "checksum": "598cba41c17504cef57c0a1224d47c94", + "baseChecksum": "598cba41c17504cef57c0a1224d47c94", + "status": "new" + }, + "de": { + "checksum": "598cba41c17504cef57c0a1224d47c94", + "baseChecksum": "598cba41c17504cef57c0a1224d47c94", + "status": "new" + } + }, + "invalidCharacter": { + "en": { + "checksum": "8e1b8cc8fe4fd447eba9e2886eb42f1c" + }, + "es": { + "checksum": "8e1b8cc8fe4fd447eba9e2886eb42f1c", + "baseChecksum": "8e1b8cc8fe4fd447eba9e2886eb42f1c", + "status": "new" + }, + "fr-ca": { + "checksum": "8e1b8cc8fe4fd447eba9e2886eb42f1c", + "baseChecksum": "8e1b8cc8fe4fd447eba9e2886eb42f1c", + "status": "new" + }, + "ru": { + "checksum": "8e1b8cc8fe4fd447eba9e2886eb42f1c", + "baseChecksum": "8e1b8cc8fe4fd447eba9e2886eb42f1c", + "status": "new" + }, + "ja": { + "checksum": "8e1b8cc8fe4fd447eba9e2886eb42f1c", + "baseChecksum": "8e1b8cc8fe4fd447eba9e2886eb42f1c", + "status": "new" + }, + "de": { + "checksum": "8e1b8cc8fe4fd447eba9e2886eb42f1c", + "baseChecksum": "8e1b8cc8fe4fd447eba9e2886eb42f1c", + "status": "new" + } + } +} diff --git a/apps/tracker/src/i18n/settings/preferredTerminology/resource_entries.json b/apps/tracker/src/i18n/settings/preferredTerminology/resource_entries.json new file mode 100644 index 0000000..c75d0eb --- /dev/null +++ b/apps/tracker/src/i18n/settings/preferredTerminology/resource_entries.json @@ -0,0 +1,182 @@ +{ + "title": { + "source": "Preferred Terminology", + "comment": "Settings section heading for the list of discouraged terms and their preferred replacements", + "tags": ["settings"], + "es": "Preferred Terminology", + "fr-ca": "Preferred Terminology", + "ru": "Preferred Terminology", + "ja": "Preferred Terminology", + "de": "Preferred Terminology" + }, + "hint": { + "source": "Base-locale text that uses a discouraged term gets a suggestion to use the preferred term instead. It is advice only: nothing is blocked.", + "comment": "Explanatory note under the Preferred Terminology heading in Settings", + "tags": ["settings"], + "es": "Base-locale text that uses a discouraged term gets a suggestion to use the preferred term instead. It is advice only: nothing is blocked.", + "fr-ca": "Base-locale text that uses a discouraged term gets a suggestion to use the preferred term instead. It is advice only: nothing is blocked.", + "ru": "Base-locale text that uses a discouraged term gets a suggestion to use the preferred term instead. It is advice only: nothing is blocked.", + "ja": "Base-locale text that uses a discouraged term gets a suggestion to use the preferred term instead. It is advice only: nothing is blocked.", + "de": "Base-locale text that uses a discouraged term gets a suggestion to use the preferred term instead. It is advice only: nothing is blocked." + }, + "countX": { + "source": "{ count, plural, =0 {No rules} =1 {1 rule} other {{count} rules} }", + "comment": "Badge next to the Preferred Terminology heading showing how many rules a save would write", + "tags": ["settings"], + "es": "{ count, plural, =0 {No rules} =1 {1 rule} other {{count} rules} }", + "fr-ca": "{ count, plural, =0 {No rules} =1 {1 rule} other {{count} rules} }", + "ru": "{ count, plural, =0 {No rules} =1 {1 rule} other {{count} rules} }", + "ja": "{ count, plural, =0 {No rules} =1 {1 rule} other {{count} rules} }", + "de": "{ count, plural, =0 {No rules} =1 {1 rule} other {{count} rules} }" + }, + "discouragedColumn": { + "source": "Discouraged term", + "comment": "Column header above the discouraged-term inputs (the old term authors should stop using)", + "tags": ["settings"], + "es": "Discouraged term", + "fr-ca": "Discouraged term", + "ru": "Discouraged term", + "ja": "Discouraged term", + "de": "Discouraged term" + }, + "preferredColumn": { + "source": "Preferred term", + "comment": "Column header above the preferred-term inputs (the term to use instead)", + "tags": ["settings"], + "es": "Preferred term", + "fr-ca": "Preferred term", + "ru": "Preferred term", + "ja": "Preferred term", + "de": "Preferred term" + }, + "reasonColumn": { + "source": "Reason (optional)", + "comment": "Column header above the optional reason inputs explaining why a term is discouraged", + "tags": ["settings"], + "es": "Reason (optional)", + "fr-ca": "Reason (optional)", + "ru": "Reason (optional)", + "ja": "Reason (optional)", + "de": "Reason (optional)" + }, + "reasonPlaceholder": { + "source": "Why the change?", + "comment": "Placeholder in the optional reason input of a preferred terminology rule", + "tags": ["settings"], + "es": "Why the change?", + "fr-ca": "Why the change?", + "ru": "Why the change?", + "ja": "Why the change?", + "de": "Why the change?" + }, + "discouragedAriaX": { + "source": "Discouraged term, rule {row}", + "comment": "Screen-reader label for the discouraged-term input of rule number {{ row }}", + "tags": ["settings"], + "es": "Discouraged term, rule {row}", + "fr-ca": "Discouraged term, rule {row}", + "ru": "Discouraged term, rule {row}", + "ja": "Discouraged term, rule {row}", + "de": "Discouraged term, rule {row}" + }, + "preferredAriaX": { + "source": "Preferred term, rule {row}", + "comment": "Screen-reader label for the preferred-term input of rule number {{ row }}", + "tags": ["settings"], + "es": "Preferred term, rule {row}", + "fr-ca": "Preferred term, rule {row}", + "ru": "Preferred term, rule {row}", + "ja": "Preferred term, rule {row}", + "de": "Preferred term, rule {row}" + }, + "reasonAriaX": { + "source": "Reason, rule {row}", + "comment": "Screen-reader label for the optional reason input of rule number {{ row }}", + "tags": ["settings"], + "es": "Reason, rule {row}", + "fr-ca": "Reason, rule {row}", + "ru": "Reason, rule {row}", + "ja": "Reason, rule {row}", + "de": "Reason, rule {row}" + }, + "addButton": { + "source": "Add rule", + "comment": "Button that appends a new blank preferred terminology rule row", + "tags": ["settings"], + "es": "Add rule", + "fr-ca": "Add rule", + "ru": "Add rule", + "ja": "Add rule", + "de": "Add rule" + }, + "removeAriaX": { + "source": "Remove rule {term}", + "comment": "Screen-reader label for the button removing the rule whose discouraged term is {{ term }}", + "tags": ["settings"], + "es": "Remove rule {term}", + "fr-ca": "Remove rule {term}", + "ru": "Remove rule {term}", + "ja": "Remove rule {term}", + "de": "Remove rule {term}" + }, + "removeEmptyAriaX": { + "source": "Remove empty rule {row}", + "comment": "Screen-reader label for the button removing rule number {{ row }}, which has no discouraged term yet", + "tags": ["settings"], + "es": "Remove empty rule {row}", + "fr-ca": "Remove empty rule {row}", + "ru": "Remove empty rule {row}", + "ja": "Remove empty rule {row}", + "de": "Remove empty rule {row}" + }, + "listAriaLabel": { + "source": "Preferred terminology rules", + "comment": "Screen-reader label for the list of preferred terminology rules", + "tags": ["settings"], + "es": "Preferred terminology rules", + "fr-ca": "Preferred terminology rules", + "ru": "Preferred terminology rules", + "ja": "Preferred terminology rules", + "de": "Preferred terminology rules" + }, + "emptyTitle": { + "source": "No preferred terminology yet", + "comment": "Empty-state title when no preferred terminology rules exist", + "tags": ["settings"], + "es": "No preferred terminology yet", + "fr-ca": "No preferred terminology yet", + "ru": "No preferred terminology yet", + "ja": "No preferred terminology yet", + "de": "No preferred terminology yet" + }, + "emptyMessage": { + "source": "Add a term your product no longer uses and the term that replaced it. Authors get a suggestion whenever base-locale text uses the old one.", + "comment": "Empty-state message when no preferred terminology rules exist", + "tags": ["settings"], + "es": "Add a term your product no longer uses and the term that replaced it. Authors get a suggestion whenever base-locale text uses the old one.", + "fr-ca": "Add a term your product no longer uses and the term that replaced it. Authors get a suggestion whenever base-locale text uses the old one.", + "ru": "Add a term your product no longer uses and the term that replaced it. Authors get a suggestion whenever base-locale text uses the old one.", + "ja": "Add a term your product no longer uses and the term that replaced it. Authors get a suggestion whenever base-locale text uses the old one.", + "de": "Add a term your product no longer uses and the term that replaced it. Authors get a suggestion whenever base-locale text uses the old one." + }, + "loadError": { + "source": "The preferred terminology file could not be read, so no rules are in force. Saving replaces it with the rules below.", + "comment": "Error banner when the preferred terminology file is broken; the raw error detail is shown beneath it", + "tags": ["settings"], + "es": "The preferred terminology file could not be read, so no rules are in force. Saving replaces it with the rules below.", + "fr-ca": "The preferred terminology file could not be read, so no rules are in force. Saving replaces it with the rules below.", + "ru": "The preferred terminology file could not be read, so no rules are in force. Saving replaces it with the rules below.", + "ja": "The preferred terminology file could not be read, so no rules are in force. Saving replaces it with the rules below.", + "de": "The preferred terminology file could not be read, so no rules are in force. Saving replaces it with the rules below." + }, + "missingFile": { + "source": "The configured preferred terminology file does not exist yet. Saving creates it.", + "comment": "Note when the config points at a preferred terminology file that does not exist", + "tags": ["settings"], + "es": "The configured preferred terminology file does not exist yet. Saving creates it.", + "fr-ca": "The configured preferred terminology file does not exist yet. Saving creates it.", + "ru": "The configured preferred terminology file does not exist yet. Saving creates it.", + "ja": "The configured preferred terminology file does not exist yet. Saving creates it.", + "de": "The configured preferred terminology file does not exist yet. Saving creates it." + } +} diff --git a/apps/tracker/src/i18n/settings/preferredTerminology/tracker_meta.json b/apps/tracker/src/i18n/settings/preferredTerminology/tracker_meta.json new file mode 100644 index 0000000..dea86d5 --- /dev/null +++ b/apps/tracker/src/i18n/settings/preferredTerminology/tracker_meta.json @@ -0,0 +1,542 @@ +{ + "title": { + "en": { + "checksum": "8aad17dbeb0e431c58a7c3d0c27be4a9" + }, + "es": { + "checksum": "8aad17dbeb0e431c58a7c3d0c27be4a9", + "baseChecksum": "8aad17dbeb0e431c58a7c3d0c27be4a9", + "status": "new" + }, + "fr-ca": { + "checksum": "8aad17dbeb0e431c58a7c3d0c27be4a9", + "baseChecksum": "8aad17dbeb0e431c58a7c3d0c27be4a9", + "status": "new" + }, + "ru": { + "checksum": "8aad17dbeb0e431c58a7c3d0c27be4a9", + "baseChecksum": "8aad17dbeb0e431c58a7c3d0c27be4a9", + "status": "new" + }, + "ja": { + "checksum": "8aad17dbeb0e431c58a7c3d0c27be4a9", + "baseChecksum": "8aad17dbeb0e431c58a7c3d0c27be4a9", + "status": "new" + }, + "de": { + "checksum": "8aad17dbeb0e431c58a7c3d0c27be4a9", + "baseChecksum": "8aad17dbeb0e431c58a7c3d0c27be4a9", + "status": "new" + } + }, + "hint": { + "en": { + "checksum": "32e1c259a7f0bbe529d86d45972062f8" + }, + "es": { + "checksum": "32e1c259a7f0bbe529d86d45972062f8", + "baseChecksum": "32e1c259a7f0bbe529d86d45972062f8", + "status": "new" + }, + "fr-ca": { + "checksum": "32e1c259a7f0bbe529d86d45972062f8", + "baseChecksum": "32e1c259a7f0bbe529d86d45972062f8", + "status": "new" + }, + "ru": { + "checksum": "32e1c259a7f0bbe529d86d45972062f8", + "baseChecksum": "32e1c259a7f0bbe529d86d45972062f8", + "status": "new" + }, + "ja": { + "checksum": "32e1c259a7f0bbe529d86d45972062f8", + "baseChecksum": "32e1c259a7f0bbe529d86d45972062f8", + "status": "new" + }, + "de": { + "checksum": "32e1c259a7f0bbe529d86d45972062f8", + "baseChecksum": "32e1c259a7f0bbe529d86d45972062f8", + "status": "new" + } + }, + "countX": { + "en": { + "checksum": "cb116742f66345953ca01039bc3461e2" + }, + "es": { + "checksum": "cb116742f66345953ca01039bc3461e2", + "baseChecksum": "cb116742f66345953ca01039bc3461e2", + "status": "new" + }, + "fr-ca": { + "checksum": "cb116742f66345953ca01039bc3461e2", + "baseChecksum": "cb116742f66345953ca01039bc3461e2", + "status": "new" + }, + "ru": { + "checksum": "cb116742f66345953ca01039bc3461e2", + "baseChecksum": "cb116742f66345953ca01039bc3461e2", + "status": "new" + }, + "ja": { + "checksum": "cb116742f66345953ca01039bc3461e2", + "baseChecksum": "cb116742f66345953ca01039bc3461e2", + "status": "new" + }, + "de": { + "checksum": "cb116742f66345953ca01039bc3461e2", + "baseChecksum": "cb116742f66345953ca01039bc3461e2", + "status": "new" + } + }, + "discouragedColumn": { + "en": { + "checksum": "db75645c771a9a3ffefa70ea17d24a78" + }, + "es": { + "checksum": "db75645c771a9a3ffefa70ea17d24a78", + "baseChecksum": "db75645c771a9a3ffefa70ea17d24a78", + "status": "new" + }, + "fr-ca": { + "checksum": "db75645c771a9a3ffefa70ea17d24a78", + "baseChecksum": "db75645c771a9a3ffefa70ea17d24a78", + "status": "new" + }, + "ru": { + "checksum": "db75645c771a9a3ffefa70ea17d24a78", + "baseChecksum": "db75645c771a9a3ffefa70ea17d24a78", + "status": "new" + }, + "ja": { + "checksum": "db75645c771a9a3ffefa70ea17d24a78", + "baseChecksum": "db75645c771a9a3ffefa70ea17d24a78", + "status": "new" + }, + "de": { + "checksum": "db75645c771a9a3ffefa70ea17d24a78", + "baseChecksum": "db75645c771a9a3ffefa70ea17d24a78", + "status": "new" + } + }, + "preferredColumn": { + "en": { + "checksum": "77cc665ef9d37c350b219b265e1bf001" + }, + "es": { + "checksum": "77cc665ef9d37c350b219b265e1bf001", + "baseChecksum": "77cc665ef9d37c350b219b265e1bf001", + "status": "new" + }, + "fr-ca": { + "checksum": "77cc665ef9d37c350b219b265e1bf001", + "baseChecksum": "77cc665ef9d37c350b219b265e1bf001", + "status": "new" + }, + "ru": { + "checksum": "77cc665ef9d37c350b219b265e1bf001", + "baseChecksum": "77cc665ef9d37c350b219b265e1bf001", + "status": "new" + }, + "ja": { + "checksum": "77cc665ef9d37c350b219b265e1bf001", + "baseChecksum": "77cc665ef9d37c350b219b265e1bf001", + "status": "new" + }, + "de": { + "checksum": "77cc665ef9d37c350b219b265e1bf001", + "baseChecksum": "77cc665ef9d37c350b219b265e1bf001", + "status": "new" + } + }, + "reasonColumn": { + "en": { + "checksum": "170556ec5afd4fec2fb4116ddc669ffb" + }, + "es": { + "checksum": "170556ec5afd4fec2fb4116ddc669ffb", + "baseChecksum": "170556ec5afd4fec2fb4116ddc669ffb", + "status": "new" + }, + "fr-ca": { + "checksum": "170556ec5afd4fec2fb4116ddc669ffb", + "baseChecksum": "170556ec5afd4fec2fb4116ddc669ffb", + "status": "new" + }, + "ru": { + "checksum": "170556ec5afd4fec2fb4116ddc669ffb", + "baseChecksum": "170556ec5afd4fec2fb4116ddc669ffb", + "status": "new" + }, + "ja": { + "checksum": "170556ec5afd4fec2fb4116ddc669ffb", + "baseChecksum": "170556ec5afd4fec2fb4116ddc669ffb", + "status": "new" + }, + "de": { + "checksum": "170556ec5afd4fec2fb4116ddc669ffb", + "baseChecksum": "170556ec5afd4fec2fb4116ddc669ffb", + "status": "new" + } + }, + "reasonPlaceholder": { + "en": { + "checksum": "53f378b13f59c25251f239004f5bc679" + }, + "es": { + "checksum": "53f378b13f59c25251f239004f5bc679", + "baseChecksum": "53f378b13f59c25251f239004f5bc679", + "status": "new" + }, + "fr-ca": { + "checksum": "53f378b13f59c25251f239004f5bc679", + "baseChecksum": "53f378b13f59c25251f239004f5bc679", + "status": "new" + }, + "ru": { + "checksum": "53f378b13f59c25251f239004f5bc679", + "baseChecksum": "53f378b13f59c25251f239004f5bc679", + "status": "new" + }, + "ja": { + "checksum": "53f378b13f59c25251f239004f5bc679", + "baseChecksum": "53f378b13f59c25251f239004f5bc679", + "status": "new" + }, + "de": { + "checksum": "53f378b13f59c25251f239004f5bc679", + "baseChecksum": "53f378b13f59c25251f239004f5bc679", + "status": "new" + } + }, + "discouragedAriaX": { + "en": { + "checksum": "1bf3057e73169f89d8184774ea6c7777" + }, + "es": { + "checksum": "1bf3057e73169f89d8184774ea6c7777", + "baseChecksum": "1bf3057e73169f89d8184774ea6c7777", + "status": "new" + }, + "fr-ca": { + "checksum": "1bf3057e73169f89d8184774ea6c7777", + "baseChecksum": "1bf3057e73169f89d8184774ea6c7777", + "status": "new" + }, + "ru": { + "checksum": "1bf3057e73169f89d8184774ea6c7777", + "baseChecksum": "1bf3057e73169f89d8184774ea6c7777", + "status": "new" + }, + "ja": { + "checksum": "1bf3057e73169f89d8184774ea6c7777", + "baseChecksum": "1bf3057e73169f89d8184774ea6c7777", + "status": "new" + }, + "de": { + "checksum": "1bf3057e73169f89d8184774ea6c7777", + "baseChecksum": "1bf3057e73169f89d8184774ea6c7777", + "status": "new" + } + }, + "preferredAriaX": { + "en": { + "checksum": "84771e03caa38a0ca41d38c50d5e7da7" + }, + "es": { + "checksum": "84771e03caa38a0ca41d38c50d5e7da7", + "baseChecksum": "84771e03caa38a0ca41d38c50d5e7da7", + "status": "new" + }, + "fr-ca": { + "checksum": "84771e03caa38a0ca41d38c50d5e7da7", + "baseChecksum": "84771e03caa38a0ca41d38c50d5e7da7", + "status": "new" + }, + "ru": { + "checksum": "84771e03caa38a0ca41d38c50d5e7da7", + "baseChecksum": "84771e03caa38a0ca41d38c50d5e7da7", + "status": "new" + }, + "ja": { + "checksum": "84771e03caa38a0ca41d38c50d5e7da7", + "baseChecksum": "84771e03caa38a0ca41d38c50d5e7da7", + "status": "new" + }, + "de": { + "checksum": "84771e03caa38a0ca41d38c50d5e7da7", + "baseChecksum": "84771e03caa38a0ca41d38c50d5e7da7", + "status": "new" + } + }, + "reasonAriaX": { + "en": { + "checksum": "c71738bb9a53182e6fe0b882352f5071" + }, + "es": { + "checksum": "c71738bb9a53182e6fe0b882352f5071", + "baseChecksum": "c71738bb9a53182e6fe0b882352f5071", + "status": "new" + }, + "fr-ca": { + "checksum": "c71738bb9a53182e6fe0b882352f5071", + "baseChecksum": "c71738bb9a53182e6fe0b882352f5071", + "status": "new" + }, + "ru": { + "checksum": "c71738bb9a53182e6fe0b882352f5071", + "baseChecksum": "c71738bb9a53182e6fe0b882352f5071", + "status": "new" + }, + "ja": { + "checksum": "c71738bb9a53182e6fe0b882352f5071", + "baseChecksum": "c71738bb9a53182e6fe0b882352f5071", + "status": "new" + }, + "de": { + "checksum": "c71738bb9a53182e6fe0b882352f5071", + "baseChecksum": "c71738bb9a53182e6fe0b882352f5071", + "status": "new" + } + }, + "addButton": { + "en": { + "checksum": "9b5e7fc14b157afb268b71b01d397eef" + }, + "es": { + "checksum": "9b5e7fc14b157afb268b71b01d397eef", + "baseChecksum": "9b5e7fc14b157afb268b71b01d397eef", + "status": "new" + }, + "fr-ca": { + "checksum": "9b5e7fc14b157afb268b71b01d397eef", + "baseChecksum": "9b5e7fc14b157afb268b71b01d397eef", + "status": "new" + }, + "ru": { + "checksum": "9b5e7fc14b157afb268b71b01d397eef", + "baseChecksum": "9b5e7fc14b157afb268b71b01d397eef", + "status": "new" + }, + "ja": { + "checksum": "9b5e7fc14b157afb268b71b01d397eef", + "baseChecksum": "9b5e7fc14b157afb268b71b01d397eef", + "status": "new" + }, + "de": { + "checksum": "9b5e7fc14b157afb268b71b01d397eef", + "baseChecksum": "9b5e7fc14b157afb268b71b01d397eef", + "status": "new" + } + }, + "removeAriaX": { + "en": { + "checksum": "797d0d4a9180381c7904b6c73ad4564e" + }, + "es": { + "checksum": "797d0d4a9180381c7904b6c73ad4564e", + "baseChecksum": "797d0d4a9180381c7904b6c73ad4564e", + "status": "new" + }, + "fr-ca": { + "checksum": "797d0d4a9180381c7904b6c73ad4564e", + "baseChecksum": "797d0d4a9180381c7904b6c73ad4564e", + "status": "new" + }, + "ru": { + "checksum": "797d0d4a9180381c7904b6c73ad4564e", + "baseChecksum": "797d0d4a9180381c7904b6c73ad4564e", + "status": "new" + }, + "ja": { + "checksum": "797d0d4a9180381c7904b6c73ad4564e", + "baseChecksum": "797d0d4a9180381c7904b6c73ad4564e", + "status": "new" + }, + "de": { + "checksum": "797d0d4a9180381c7904b6c73ad4564e", + "baseChecksum": "797d0d4a9180381c7904b6c73ad4564e", + "status": "new" + } + }, + "removeEmptyAriaX": { + "en": { + "checksum": "a54cb85dd76f907324d67de6b38cd633" + }, + "es": { + "checksum": "a54cb85dd76f907324d67de6b38cd633", + "baseChecksum": "a54cb85dd76f907324d67de6b38cd633", + "status": "new" + }, + "fr-ca": { + "checksum": "a54cb85dd76f907324d67de6b38cd633", + "baseChecksum": "a54cb85dd76f907324d67de6b38cd633", + "status": "new" + }, + "ru": { + "checksum": "a54cb85dd76f907324d67de6b38cd633", + "baseChecksum": "a54cb85dd76f907324d67de6b38cd633", + "status": "new" + }, + "ja": { + "checksum": "a54cb85dd76f907324d67de6b38cd633", + "baseChecksum": "a54cb85dd76f907324d67de6b38cd633", + "status": "new" + }, + "de": { + "checksum": "a54cb85dd76f907324d67de6b38cd633", + "baseChecksum": "a54cb85dd76f907324d67de6b38cd633", + "status": "new" + } + }, + "listAriaLabel": { + "en": { + "checksum": "b04cd032abf2faa4a4124e4ff1d759b1" + }, + "es": { + "checksum": "b04cd032abf2faa4a4124e4ff1d759b1", + "baseChecksum": "b04cd032abf2faa4a4124e4ff1d759b1", + "status": "new" + }, + "fr-ca": { + "checksum": "b04cd032abf2faa4a4124e4ff1d759b1", + "baseChecksum": "b04cd032abf2faa4a4124e4ff1d759b1", + "status": "new" + }, + "ru": { + "checksum": "b04cd032abf2faa4a4124e4ff1d759b1", + "baseChecksum": "b04cd032abf2faa4a4124e4ff1d759b1", + "status": "new" + }, + "ja": { + "checksum": "b04cd032abf2faa4a4124e4ff1d759b1", + "baseChecksum": "b04cd032abf2faa4a4124e4ff1d759b1", + "status": "new" + }, + "de": { + "checksum": "b04cd032abf2faa4a4124e4ff1d759b1", + "baseChecksum": "b04cd032abf2faa4a4124e4ff1d759b1", + "status": "new" + } + }, + "emptyTitle": { + "en": { + "checksum": "61652297718f168386d5a81625898677" + }, + "es": { + "checksum": "61652297718f168386d5a81625898677", + "baseChecksum": "61652297718f168386d5a81625898677", + "status": "new" + }, + "fr-ca": { + "checksum": "61652297718f168386d5a81625898677", + "baseChecksum": "61652297718f168386d5a81625898677", + "status": "new" + }, + "ru": { + "checksum": "61652297718f168386d5a81625898677", + "baseChecksum": "61652297718f168386d5a81625898677", + "status": "new" + }, + "ja": { + "checksum": "61652297718f168386d5a81625898677", + "baseChecksum": "61652297718f168386d5a81625898677", + "status": "new" + }, + "de": { + "checksum": "61652297718f168386d5a81625898677", + "baseChecksum": "61652297718f168386d5a81625898677", + "status": "new" + } + }, + "emptyMessage": { + "en": { + "checksum": "142990b0160228e59c4ff5e71a044249" + }, + "es": { + "checksum": "142990b0160228e59c4ff5e71a044249", + "baseChecksum": "142990b0160228e59c4ff5e71a044249", + "status": "new" + }, + "fr-ca": { + "checksum": "142990b0160228e59c4ff5e71a044249", + "baseChecksum": "142990b0160228e59c4ff5e71a044249", + "status": "new" + }, + "ru": { + "checksum": "142990b0160228e59c4ff5e71a044249", + "baseChecksum": "142990b0160228e59c4ff5e71a044249", + "status": "new" + }, + "ja": { + "checksum": "142990b0160228e59c4ff5e71a044249", + "baseChecksum": "142990b0160228e59c4ff5e71a044249", + "status": "new" + }, + "de": { + "checksum": "142990b0160228e59c4ff5e71a044249", + "baseChecksum": "142990b0160228e59c4ff5e71a044249", + "status": "new" + } + }, + "loadError": { + "en": { + "checksum": "1f6cb43e016af574a92b6c689f3fe20c" + }, + "es": { + "checksum": "1f6cb43e016af574a92b6c689f3fe20c", + "baseChecksum": "1f6cb43e016af574a92b6c689f3fe20c", + "status": "new" + }, + "fr-ca": { + "checksum": "1f6cb43e016af574a92b6c689f3fe20c", + "baseChecksum": "1f6cb43e016af574a92b6c689f3fe20c", + "status": "new" + }, + "ru": { + "checksum": "1f6cb43e016af574a92b6c689f3fe20c", + "baseChecksum": "1f6cb43e016af574a92b6c689f3fe20c", + "status": "new" + }, + "ja": { + "checksum": "1f6cb43e016af574a92b6c689f3fe20c", + "baseChecksum": "1f6cb43e016af574a92b6c689f3fe20c", + "status": "new" + }, + "de": { + "checksum": "1f6cb43e016af574a92b6c689f3fe20c", + "baseChecksum": "1f6cb43e016af574a92b6c689f3fe20c", + "status": "new" + } + }, + "missingFile": { + "en": { + "checksum": "252f73de9f69b2d9494af6063daadaab" + }, + "es": { + "checksum": "252f73de9f69b2d9494af6063daadaab", + "baseChecksum": "252f73de9f69b2d9494af6063daadaab", + "status": "new" + }, + "fr-ca": { + "checksum": "252f73de9f69b2d9494af6063daadaab", + "baseChecksum": "252f73de9f69b2d9494af6063daadaab", + "status": "new" + }, + "ru": { + "checksum": "252f73de9f69b2d9494af6063daadaab", + "baseChecksum": "252f73de9f69b2d9494af6063daadaab", + "status": "new" + }, + "ja": { + "checksum": "252f73de9f69b2d9494af6063daadaab", + "baseChecksum": "252f73de9f69b2d9494af6063daadaab", + "status": "new" + }, + "de": { + "checksum": "252f73de9f69b2d9494af6063daadaab", + "baseChecksum": "252f73de9f69b2d9494af6063daadaab", + "status": "new" + } + } +} diff --git a/apps/tracker/src/testing/transloco-testing.module.ts b/apps/tracker/src/testing/transloco-testing.module.ts index 3f67f31..4c1de0e 100644 --- a/apps/tracker/src/testing/transloco-testing.module.ts +++ b/apps/tracker/src/testing/transloco-testing.module.ts @@ -91,6 +91,9 @@ export function getTranslocoTestingModule(options: TranslocoTestingOptions = {}) 'Entry name only — paste a full dotted key and its folder part moves to Location', 'browser.translationEditor.locationFromKeyX': 'Location set to {{ folder }} from the key you entered', 'browser.translationEditor.baseBadge': 'Base', + 'browser.translationEditor.preferredTerm.messageX': + 'Preferred terminology: consider “{{ preferred }}” instead of “{{ discouraged }}”.', + 'browser.translationEditor.preferredTerm.useX': 'Use “{{ preferred }}”', 'browser.translationEditor.localeTranslationX': '{{ locale }} Translation', 'browser.translationEditor.enterTranslationX': 'Enter {{ locale }} translation...', 'browser.translationEditor.baseValueRequired': 'Base translation is required', diff --git a/architecture-docs/api.md b/architecture-docs/api.md index 2ed5eab..c8fb35c 100644 --- a/architecture-docs/api.md +++ b/architecture-docs/api.md @@ -35,8 +35,8 @@ All paths are relative to the `/api` global prefix. URL path parameters that con | Method | Path | Purpose | Request DTO | Response DTO | |--------|------|---------|-------------|--------------| -| `GET` | `/config` | Read global config and all collection configs, with protected terms resolved from their files | — | `LingoTrackerConfigDto` | -| `PUT` | `/config` | Update the writable top-level globals. Today that is `protectedTerms` alone. The handler writes it to the global protected-terms **file**, and leaves `.lingo-tracker.json` untouched. `collections`, `locales`, and `baseLocale` stay excluded on purpose. | `UpdateConfigDto` | `{ message: string }` | +| `GET` | `/config` | Read global config and all collection configs, with protected terms resolved from their files. Also carries the preferred terminology rules, the rule file path, and any load error or missing-file warning. | — | `LingoTrackerConfigDto` | +| `PUT` | `/config` | Update the writable top-level globals: `protectedTerms` and `preferredTerminology`. The handler writes each one to its own **file**, and leaves `.lingo-tracker.json` untouched. `preferredTerminology` is the full rule list. The server validates it again and returns `400` with per-row errors when a rule is invalid. `collections`, `locales`, and `baseLocale` stay excluded on purpose. | `UpdateConfigDto` | `{ message: string }` | ### Collections diff --git a/architecture-docs/feature-matrix.md b/architecture-docs/feature-matrix.md index b773ab1..33814a8 100644 --- a/architecture-docs/feature-matrix.md +++ b/architecture-docs/feature-matrix.md @@ -67,6 +67,10 @@ Each cell shows whether the operation is supported (`Yes`), not supported (`—` | List the terms that apply (global or per collection) | Yes (`protected-terms --list`) | Yes (resolved on `GET /config`) | Yes (Settings page, and collection edit dialog) | | Add, remove, or replace terms | Yes (`protected-terms --add/--remove/--set`) | Yes (`PUT /config`, and `PUT /collections/:name`) | Yes (chip editors. Collection chips need a terms file first.) | | Name the terms file for a scope | Yes (`protected-terms --file`) | Yes (`protectedTermsFile` on `PUT /collections/:name`) | — (shows the resolved path, read-only) | +| **[Preferred Terminology](glossary.md#preferred-terminology)** | | | | +| List the rules | Yes (`preferred-terminology --list`) | Yes (`GET /config`, with the file path and any load error) | Yes (Settings page) | +| Add, edit, or remove rules | Yes (`preferred-terminology --add/--remove`. `--add` replaces an existing rule.) | Yes (`PUT /config` with the full rule list. Invalid rules return `400` with per-row errors.) | Yes (Settings page rule table) | +| Warn about discouraged terms in base-locale values | Yes (`add-resource`, `edit-resource`, base-locale `import`, `validate`) | — (create and update responses carry no findings) | Yes (translation editor notes, with a "Use …" fix that does not save) | | **Validation** | | | | | Validate all resources (CI gate) | Yes (`validate`) | — | — | | View resource status per locale | — | — | Yes (status badge per locale row in item) | diff --git a/architecture-docs/glossary.md b/architecture-docs/glossary.md index 86b5271..bab65df 100644 --- a/architecture-docs/glossary.md +++ b/architecture-docs/glossary.md @@ -87,6 +87,20 @@ Explained in context: [`domain-and-data-model.md`](domain-and-data-model.md) ## P +### Preferred Terminology + +A global list of rules. Each rule maps a **discouraged** source-language term to the **preferred** term, with an optional `reason`. LingoTracker warns when a base-locale value uses a discouraged term, and suggests the preferred one. It never blocks. Only an unreadable rule file fails `validate`. + +The rules are project configuration rather than resource data. They live in a standalone JSON file, a bare array of rule objects. `.lingo-tracker.json` names that file with `preferredTerminologyFile`. Omit the setting and the rules fall back to `.lingo-tracker-preferred-terminology.json` beside the config. Collections cannot override the rules. + +Matching is case-insensitive and whole-word, and it covers only the text a reader sees. ICU arguments and selectors, Transloco placeholders, and tags are skipped. The pure rule and matching functions live in `libs/domain`, so the Tracker UI and core share them. + +Contrast with [Protected Term](#protected-term), which keeps a word unchanged in translations and blocks imports that alter it. + +Explained in context: [`docs/features/preferred-terminology.md`](../docs/features/preferred-terminology.md) + +--- + ### Protected Term A word that must stay unchanged through translation. A brand name, a product name, or a piece of jargon all qualify. `iPhone` stays `iPhone` in every locale. diff --git a/docs/cli.md b/docs/cli.md index 7f0cb8c..10b22f8 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -300,6 +300,53 @@ The [Protected Terms](./features/protected-terms.md) page explains how export an --- +### preferred-terminology + +Manage preferred terminology rules. A rule names a discouraged term, the term to use instead, and an optional reason. When a base-locale value uses a discouraged term, LingoTracker warns and suggests the preferred term. It never blocks. The rules live in a JSON file of their own, and this command reads and writes that file. + +**Usage:** + +```bash +lingo-tracker preferred-terminology [options] +``` + +**Options:** + +- `--list` - Print the rules and the file that holds them +- `--add ` - Add a rule. If a rule for the same discouraged term exists (any casing), replace it. Requires `--preferred`. +- `--preferred ` - The preferred term for `--add` +- `--reason ` - Optional reason for `--add` +- `--remove ` - Remove the rule for a discouraged term (any casing). An unknown term is an error. + +`--add` and `--remove` take one term each and cannot be combined in one run. + +**Examples:** + +List the rules and their file: +```bash +lingo-tracker preferred-terminology --list +``` + +Add a rule. The file is created if it is absent: +```bash +lingo-tracker preferred-terminology --add "Expenditure" --preferred "Investment" --reason "Brand voice" +``` + +Remove a rule: +```bash +lingo-tracker preferred-terminology --remove "Expenditure" +``` + +**Notes:** +- The file is `.lingo-tracker-preferred-terminology.json` beside `.lingo-tracker.json` by default. Set `preferredTerminologyFile` in `.lingo-tracker.json` to use another path. +- `--add` on an existing term replaces the whole rule. Leaving out `--reason` clears any previous reason. +- LingoTracker rejects invalid rules and leaves the file untouched. For example, a preferred term that is itself discouraged is rejected. +- A malformed rules file makes `--list` report the error. `--add` and `--remove` refuse to run until you fix it, so they never overwrite it. + +The [Preferred Terminology](./features/preferred-terminology.md) page explains how matching works and where the warnings appear. + +--- + ### delete-collection Delete a translation collection from the project. @@ -480,6 +527,7 @@ lingo-tracker add-resource \ - In interactive mode, you'll be prompted if you want to provide translations for each configured locale - Resources are placed in the appropriate folder based on the key and optional `--target-folder` - If a translation's checksum matches the base value's checksum, the status will automatically be set to `new` regardless of the provided status +- After it stores the resource, the command prints a warning for each [preferred terminology](./features/preferred-terminology.md) rule the base value breaks. The warnings never change the exit code. --- @@ -615,6 +663,7 @@ lingo-tracker edit-resource \ - Updating `--base-value` triggers a checksum update and marks all other existing translations as `stale`. - Updating a locale value sets its status to `translated` and updates its checksum. - If no changes are detected (values match existing), the command reports "No changes detected". +- When the command changes the base value, it prints a warning for each [preferred terminology](./features/preferred-terminology.md) rule the new value breaks. The warnings never change the exit code. --- @@ -1286,7 +1335,9 @@ A value that does not compile as ICU for its own locale is a failure whatever it **Exit Codes:** - `0` - All validations passed (all resources verified) -- `1` - Validation failures found (new/stale resources, translated without `--allow-translated`, or values that fail to compile as ICU) +- `1` - Validation failures found (new/stale resources, translated without `--allow-translated`, or values that fail to compile as ICU), or the preferred terminology file exists but cannot be loaded + +[Preferred terminology](./features/preferred-terminology.md) findings in base-locale values are warnings. They never change the exit code. **When to Use Validate:** @@ -1954,6 +2005,7 @@ All commands read from `.lingo-tracker.json` in the project root. This file is c - **Per-collection fields** can override global settings for specific collections - **`readOnly`** (optional, per-collection) - When `true`, resource mutations to this collection are blocked across the CLI, API, and UI. The collection can still be unregistered and its config entry edited. Defaults to `true` for `node_modules` paths when added via `add-collection`. See [add-collection](#add-collection) for details. - **`tokenCasing`** (optional) - Controls the casing style for generated type token keys. Accepts `"upperCase"` (default, SCREAMING_SNAKE_CASE) or `"camelCase"`. Can be set globally or per-bundle. See [Bundle Type Generation](./features/bundle-type-generation.md) for details. Precedence: CLI flag `--token-casing` > per-bundle config > global config > default (`"upperCase"`) +- **`preferredTerminologyFile`** (optional, global only) - Path to the JSON file that holds preferred terminology rules. LingoTracker resolves the path against the directory that contains `.lingo-tracker.json`. Omit it and the rules fall back to `.lingo-tracker-preferred-terminology.json`. See [Preferred Terminology](./features/preferred-terminology.md) for details. - **`protectedTermsFile`** (optional, global and per-collection) - Path to a JSON file that holds protected terms. LingoTracker resolves the path against the directory that contains `.lingo-tracker.json`. Omit it globally and the list falls back to `.lingo-tracker-protected-terms.json`. A collection has no default, so a collection without this setting contributes no terms of its own. LingoTracker adds a collection's terms to the global ones. See [Protected Terms](./features/protected-terms.md) for details. --- diff --git a/docs/features/glossary.md b/docs/features/glossary.md index 7936ff7..285edb7 100644 --- a/docs/features/glossary.md +++ b/docs/features/glossary.md @@ -9,6 +9,8 @@ The Glossary feature extracts a glossary of relevant translations from a block o Given a paragraph of base-locale text, the `glossary` command pulls out the meaningful terms, finds the matching translation entries, and writes a JSON glossary containing each term's translations across all locales. That glossary can be handed to whoever (or whatever) translates the help content. +The glossary is a reference that the command generates on demand. It checks nothing. To keep words unchanged in translations, use [protected terms](./protected-terms.md). To flag discouraged wording in your base-locale text, use [preferred terminology](./preferred-terminology.md). + ## Usage ```bash diff --git a/docs/features/import.md b/docs/features/import.md index 4c4dae0..a05ae88 100644 --- a/docs/features/import.md +++ b/docs/features/import.md @@ -209,6 +209,22 @@ LingoTracker matches the source case-insensitively. It requires the translation Base-locale imports skip the check, because a base-locale import defines the source rather than translating it. An import in a project with no protected terms also skips it. +## Preferred Terminology Warnings + +A base-locale import (`--strategy migration` into the base locale) checks every value it writes against the preferred terminology rules (`.lingo-tracker-preferred-terminology.json`, or the file named by `preferredTerminologyFile`). Each discouraged term found adds one warning, whichever way it is capitalized and however often it occurs in the value: + +``` +Preferred terminology: key "budget.title" — consider "Investment" instead of "Expenditure". Finance style guide +``` + +The warnings are advisory. The value is imported as-is, nothing is skipped or failed, and the exit code is unchanged. A dry run reports the same warnings. + +- **Target-locale imports are never checked.** Translations use their own vocabulary. +- **A broken rule file** (invalid JSON or invalid rules) adds one warning, `Preferred terminology checks skipped: …`, and the import continues without the check. +- **A missing file** at the default path means no rules. A missing file named explicitly by `preferredTerminologyFile` adds one warning. + +The [Preferred Terminology](./preferred-terminology.md) page explains the rules and how matching works. + ## ICU Format Auto-Fixing LingoTracker automatically fixes common ICU message format placeholder errors made by translators: diff --git a/docs/features/preferred-terminology.md b/docs/features/preferred-terminology.md new file mode 100644 index 0000000..61e0fba --- /dev/null +++ b/docs/features/preferred-terminology.md @@ -0,0 +1,243 @@ +--- +title: Preferred Terminology +sidebar_position: 7 +--- + +# Preferred Terminology + +Preferred terminology is a list of source-language terms your product no longer uses. Each rule maps a **discouraged term** to the **preferred term** that replaced it. A rule can also carry a reason. + +LingoTracker checks base-locale values against these rules. When a value uses a discouraged term, LingoTracker warns and suggests the preferred term. It never blocks a save, an import, or a release. An older term can still be correct in a quotation or a historical note, so the author decides. + +The warnings appear in five places: + +- The **resource editor** in the web UI shows a note under the base value, with a button that applies the preferred term. +- **`add-resource`** and **`edit-resource`** print a warning after they store the value. +- A **base-locale import** adds a warning to the import summary. +- **`validate`** lists every finding in its own section. +- **Settings** in the web UI, and the `preferred-terminology` CLI command, edit the rules themselves. + +## How it differs from protected terms and the glossary + +LingoTracker has three features that deal with terminology. They solve different problems. + +| | Preferred terminology | [Protected terms](./protected-terms.md) | [Translation glossary](./glossary.md) | +|---|---|---|---| +| What it is | Rules that map a discouraged source term to a preferred one | Words that must stay unchanged in every translation, such as `iPhone` | A JSON file of the app's existing translations for the terms found in a block of help text | +| Who maintains it | Your team, in Settings, with the CLI, or in the rule file | Your team, in Settings, with the CLI, or in the terms file | Nobody. The `glossary` command generates it on demand. | +| What it checks | Base-locale values that use a discouraged term | Imported translations that dropped or altered a term from the source | Nothing. Translators use it as a reference. | +| Does it block? | No. Findings are warnings. Only an unreadable rule file fails `validate`. | Yes. Import rejects a translation that altered a protected term. | No | +| Locales it looks at | The base locale only | Target locales | Reads base-locale values, and outputs target-locale translations | +| Scope | One global file | A global file, plus an optional file per collection | One run of the command, optionally limited to one collection | + +In short, preferred terminology changes the wording of your source text. Protected terms keep words intact through translation. The glossary helps translators reuse words that the app already translates. + +## The file + +The rule file holds a bare JSON array. Each rule has a `discouraged` term, a `preferred` term, and an optional `reason`. + +```json +[ + { + "discouraged": "E-mail", + "preferred": "email" + }, + { + "discouraged": "Expenditure", + "preferred": "Investment", + "reason": "Current planning term." + }, + { + "discouraged": "Log-in", + "preferred": "sign in", + "reason": "Match the product UI." + } +] +``` + +The file lives at **`.lingo-tracker-preferred-terminology.json`** by default. That file sits beside `.lingo-tracker.json`. You do not need to create it. Adding your first rule creates it. + +To keep the rules somewhere else, name the path in your configuration with `preferredTerminologyFile`. + +```json +{ + "baseLocale": "en", + "locales": ["en", "es"], + "preferredTerminologyFile": "config/preferred-terminology.json", + "collections": { + "app": { "translationsFolder": "src/i18n" } + } +} +``` + +LingoTracker resolves a relative path against the directory that holds `.lingo-tracker.json`. It uses an absolute path as written. + +There is one global rule file. A collection cannot add rules or override them. A collection with its own `baseLocale` is still checked, in that base locale. + +### How LingoTracker writes the file + +Every write replaces the whole file. The CLI and the web UI write it the same way. + +- LingoTracker sorts the rules by discouraged term, ignoring case. Adding a rule therefore produces a small diff. +- It trims spaces from every field. +- It drops a `reason` that is empty. +- It indents with two spaces and ends the file with a newline. + +LingoTracker validates the whole list before it writes. If any rule is invalid, it writes nothing and leaves the file as it was. + +The API server re-reads the file when the file changes on disk. A hand edit or a `git pull` therefore takes effect without a restart. + +## Rule validation + +LingoTracker rejects a rule list that contains any of the problems below. It compares terms after trimming, and it ignores case. So `Email → email` counts as a self-mapping. + +| Problem | Example | Why LingoTracker rejects it | +|---|---|---| +| Empty term | `"preferred": ""` | Both terms are required. | +| Not text | `"preferred": 42`, or a row that is not an object | Terms and the reason must be strings. | +| Invalid character | `Account {name}` | A term cannot contain `{`, `}`, `<`, or `>`. LingoTracker never matches inside ICU syntax or tags, so such a discouraged term could never match. Such a preferred term would break the message when applied. | +| Duplicate | `Expenditure` and `expenditure` in two rows | Each discouraged term can appear only once. LingoTracker reports the later row. | +| Self-mapping | `Email → email` | The preferred term must differ from the discouraged term. | +| Chain | `Log-in → Login` and `Login → sign in` | The preferred term is itself discouraged. Map `Log-in` straight to `sign in`. | +| Cycle | `Sign-on → Login` and `Login → Sign-on` | Following the rules leads back to the start. | +| Preferred contains a discouraged term | `Email → Email address`, or `Account → E-mail account` when `E-mail` is discouraged | Applying the suggestion would produce text that is flagged again. LingoTracker checks for a whole-word match, and it includes the rule's own discouraged term. | + +The reason is free text. LingoTracker only checks that it is a string. + +## What counts as a match + +LingoTracker matches a discouraged term as a whole word or a whole phrase, ignoring case. + +- **Letters, digits, and `_` are word characters.** Any other character ends a word, including `-` and `'`. +- **No plurals or word stems.** A rule for `Expenditure` does not match `Expenditures`. Add a second rule if you need the plural. +- **One rule, one warning per value.** A term that appears three times in one value produces one finding. + +With a rule for `Expenditure`: + +| Value | Match? | +|---|---| +| `Capital expenditure` | Yes. Case does not matter. | +| `expenditure-report` | Yes. `-` separates words. | +| `the expenditure's total` | Yes. `'` separates words. | +| `Expenditures` | No. It is a longer word. | +| `ExpenditureType` | No. It is a longer word. | +| `expenditure_id` | No. `_` joins words. | + +LingoTracker checks only the text a reader sees. It skips these parts of a value: + +- ICU argument names, formats, and styles, such as `{expenditure}` or `{amount, number, currency}` +- `select` and `plural` selectors, and the `#` symbol. The text inside each branch is still checked. +- Transloco placeholders, such as `{{ expenditure }}` +- HTML and XML tags, including their attributes. The text between tags is still checked. + +So `Expenditure for {expenditure}` gets one finding, for the first word only. + +When a value is not valid ICU, LingoTracker skips every `{…}` span and every tag, and checks the rest. + +LingoTracker checks the raw text, so it does not undo ICU quoting. A term that contains an apostrophe does not match a doubled `''` in the stored value. + +## Resource editor + +The editor checks the base-locale value while you type. After a short pause, it shows one amber note per rule the value breaks. The note reads `Preferred terminology: consider "Investment" instead of "Expenditure".` The rule's reason appears below it. + +The editor checks an existing value as soon as the dialog opens. Translations in other locales are not checked. + +Each note has a **Use "…"** button. It replaces every visible occurrence of the discouraged term with the preferred term. + +- The editor inserts the preferred term exactly as the rule spells it. For `Expenditure → Investment`, `capital expenditure` becomes `capital Investment`. Check the capitals before you save. +- ICU arguments, placeholders, and tags stay unchanged. +- The button changes the field only. You still save with **Save changes**, and you can still cancel. + +The editor hides the button when the resource is read-only. + +## Settings + +The **Preferred Terminology** section of **Settings** lists the rules as a table. Each row has a discouraged term, a preferred term, and an optional reason. + +- **Add rule** adds an empty row. The remove button deletes a row. +- Settings validates the rules as you edit. It shows the error under the field that caused it. +- **Save** stays disabled while a row has an error. One **Save** stores both the protected terms and the preferred terminology. +- The server validates the rules again before it writes the file. + +The section names the rule file it reads and writes. + +If the rule file exists but cannot be read, Settings shows an error with the details. No rules are in force until you fix the file. Saving from Settings replaces the file with the rules on screen. + +If `preferredTerminologyFile` names a file that does not exist, Settings says so. Saving creates the file. + +## CLI + +```bash +# List the rules and the file that holds them +lingo-tracker preferred-terminology --list + +# Add a rule. The file is created if it is absent. +lingo-tracker preferred-terminology --add "Expenditure" --preferred "Investment" --reason "Current planning term." + +# Replace an existing rule. Leaving out --reason clears the old reason. +lingo-tracker preferred-terminology --add "expenditure" --preferred "Spending" + +# Remove a rule +lingo-tracker preferred-terminology --remove "Expenditure" +``` + +`--add` and `--remove` find an existing rule by its discouraged term, ignoring case. `--add` on an existing term replaces the whole rule. + +If the new list breaks a validation rule, the command prints each problem, exits with code `1`, and leaves the file unchanged. If the file cannot be read, `--add` and `--remove` refuse to run, so they never overwrite it. + +The [CLI reference](../cli.md#preferred-terminology) holds the full option list. + +## Adding and editing resources + +`add-resource` stores the value first, and then prints one warning per rule the base value breaks. + +``` +✅ Resource added: budget.title +⚠️ Preferred terminology: consider "Investment" instead of "Expenditure" + Current planning term. +``` + +`edit-resource` does the same, but only when the command changes the base value. Changing a comment, tags, or a translation prints no terminology warnings. + +The warnings never change the exit code. + +## Import + +A base-locale import checks every value it creates or updates. Only the `migration` strategy can import into the base locale. Each finding adds a warning to the import summary. + +``` +Preferred terminology: key "budget.title" — consider "Investment" instead of "Expenditure". Current planning term. +``` + +The import still writes the value. Nothing is skipped or failed, and the exit code does not change. A dry run reports the same warnings. + +Imports into a target locale are not checked. The [Import](./import.md#preferred-terminology-warnings) page has the details. + +## Validate + +`validate` scans every base-locale value in every collection. It lists the findings in a **Preferred terminology warnings** section. + +``` +⚠️ Preferred terminology warnings (2): +────────────────────────────────────────────────── + [main] budget.summary: consider "Investment" instead of "Expenditure" + Current planning term. + [main] contact.help: consider "email" instead of "E-mail" +``` + +- Findings are warnings. They count towards the warning total, and they never change the exit code. +- `validate` reports each key and rule once. It does not repeat a finding per target locale or per occurrence. +- `validate` has no flag to turn the check off. Remove the rules to stop it. +- An unreadable rule file is a **failure**, and `validate` exits with code `1`. Without this, a typo in the file would switch the check off in CI without anyone noticing. + +The [Validate](./validate.md#preferred-terminology) page has the details. + +## Missing or broken files + +| Situation | Editor, `add-resource`, `edit-resource`, import | `validate` | +|---|---|---| +| No file at the default path | No rules, no message. This is the normal state before the first rule. | No rules, no message | +| No file at the path in `preferredTerminologyFile` | No rules. The CLI prints a warning, and Settings shows a note. A path to nothing is usually a typo. | A warning, then no rules | +| The file is not valid JSON, is not an array, or has an invalid rule | The check is skipped. The CLI prints a warning. The editor stays silent, and Settings shows the error. | A failure, with exit code `1` | + +LingoTracker treats an unreadable file differently in `validate` on purpose. Authoring tools should keep working while someone fixes the file. The CI check should not pass while no rules are in force. diff --git a/docs/features/protected-terms.md b/docs/features/protected-terms.md index 31ac9dd..f302903 100644 --- a/docs/features/protected-terms.md +++ b/docs/features/protected-terms.md @@ -12,6 +12,8 @@ LingoTracker applies the list in two places. - **Export** marks each exported string with the protected terms found in its source. Translators and machine-translation services then see which words to leave alone. - **Import** rejects an incoming translation when a protected term from the source is missing from it. +Protected terms are not [preferred terminology](./preferred-terminology.md). Preferred terminology suggests better wording for your base-locale text, and it only warns. Protected terms keep words intact in translations, and import enforces them. + The terms live in a **JSON file** of their own, outside `.lingo-tracker.json`. A terminology list grows to hundreds of entries. It also changes on a different schedule from the rest of your configuration. A separate file keeps your configuration diffs short, and it lets reviewers read the terminology on its own. ## The file diff --git a/docs/features/validate.md b/docs/features/validate.md index 85b5e14..5671db7 100644 --- a/docs/features/validate.md +++ b/docs/features/validate.md @@ -12,6 +12,8 @@ The Validate feature provides a comprehensive translation validation system desi The second question matters because approval is about wording, not syntax. A value can be marked `verified` and still render nothing at runtime. +Validate also scans base-locale values against the [preferred terminology](#preferred-terminology) rules. That check is advisory: it reports warnings and never fails the run. + ## Overview The validate command is a CLI-only, non-interactive tool that performs exhaustive validation of your entire translation inventory. Unlike other commands that may focus on subsets of data, validate checks **everything** and collects **all** validation results before reporting. This comprehensive approach ensures you have complete visibility into translation status before release. @@ -43,7 +45,9 @@ lingo-tracker validate [options] ### Exit Codes - `0` - All validations passed (all resources verified or only warnings) -- `1` - Validation failures found, OR configuration errors (config file missing or invalid JSON, no collections configured, no target locales configured, all target locales were skipped) +- `1` - Validation failures found, OR configuration errors (config file missing or invalid JSON, no collections configured, no target locales configured, all target locales were skipped, preferred terminology file exists but cannot be loaded) + +Preferred terminology warnings never change the exit code. ## Validation Rules @@ -107,6 +111,26 @@ It applies to the base locale only — translations keep their own categories, w `selectordinal` is deliberately exempt: English ordinal `one` selects 1, 21, 31 …, so rewriting it as `=1` would change behaviour rather than preserve it. +### Preferred Terminology + +Each collection's base-locale values are scanned for discouraged terms from the preferred terminology file (`.lingo-tracker-preferred-terminology.json` beside `.lingo-tracker.json`, or the file named by `preferredTerminologyFile`). Manage the rules with `lingo-tracker preferred-terminology`. The [Preferred Terminology](./preferred-terminology.md) page explains the rules and how matching works. + +- **Warnings only.** A finding suggests better wording; it never fails validation or changes the exit code. +- **Once per key and rule.** A term used three times in one value, in a project with five target locales, is one warning. +- **Base locale only.** Translations use their own vocabulary and are not scanned. A collection with its own `baseLocale` is scanned in that locale. +- **A broken file fails.** If the file exists but is not valid JSON or contains invalid rules, validate reports it as a failure and exits `1` — otherwise a typo would silently switch the check off in CI. A missing default file means no rules and no output. A missing file named explicitly by `preferredTerminologyFile` prints a warning and is treated as empty. +- **No opt-out flag.** Remove the rules, or the file, to stop the check. + +``` +⚠️ Preferred terminology warnings (2): +────────────────────────────────────────────────── + [main] budget.summary: consider "Investment" instead of "Expenditure" + Finance prefers investment framing + [main] contact.help: consider "email" instead of "e-mail" +``` + +Findings count towards the summary as `Total Preferred Terminology Warnings`, and a run whose only findings are terminology warnings ends with `✅ Validation passed with warnings.` + ### Validation Philosophy 1. **Strict by default**: Production deployments should only include verified translations @@ -137,7 +161,7 @@ It applies to the base locale only — translations keep their own categories, w ### What Doesn't Get Validated -- Base locale (source translations are authoritative by definition) +- Base locale status (source translations are authoritative by definition; their wording is only checked against preferred terminology, as warnings) - ICU message format syntax (use separate linting tools) - File structure or JSON validity (handled during resource loading) @@ -557,6 +581,7 @@ The validate feature is implemented across two layers: - `types.ts`: Type definitions for validation options, results, and resource details - `validate-resources.ts`: Core validation logic and resource status checking - `generate-validation-summary.ts`: Summary generation and formatting +- `validate-terminology.ts`: Preferred terminology scan of base-locale values (rules are loaded by the CLI and passed in) #### CLI Application (`apps/cli/src/commands/validate.ts`) diff --git a/libs/core/src/config/lingo-tracker-config.ts b/libs/core/src/config/lingo-tracker-config.ts index 8f551d9..1a09d02 100644 --- a/libs/core/src/config/lingo-tracker-config.ts +++ b/libs/core/src/config/lingo-tracker-config.ts @@ -1,5 +1,5 @@ -import type { LingoTrackerCollection } from './lingo-tracker-collection'; import type { BundleDefinition, TokenCasing } from './bundle-definition'; +import type { LingoTrackerCollection } from './lingo-tracker-collection'; import type { TranslationConfig } from './translation-config'; /** @@ -36,4 +36,14 @@ export interface LingoTrackerConfig { * array of strings and is unioned with each collection's file at read time. */ protectedTermsFile?: string; + + /** + * Path to the JSON file holding the preferred terminology rules — discouraged + * base-locale terms, each mapped to the term the product uses instead. Resolved against + * the directory holding this config file. When absent, defaults to + * `.lingo-tracker-preferred-terminology.json` beside the config. The file holds a bare + * JSON array of `{ discouraged, preferred, reason? }` objects. There is one global file; + * collections cannot override it. + */ + preferredTerminologyFile?: string; } diff --git a/libs/core/src/lib/config/index.ts b/libs/core/src/lib/config/index.ts index 8b0a624..d28df19 100644 --- a/libs/core/src/lib/config/index.ts +++ b/libs/core/src/lib/config/index.ts @@ -1,2 +1,3 @@ export * from './config-file-operations'; +export * from './preferred-terminology-file'; export * from './protected-terms-file'; diff --git a/libs/core/src/lib/config/preferred-terminology-file.spec.ts b/libs/core/src/lib/config/preferred-terminology-file.spec.ts new file mode 100644 index 0000000..8551b61 --- /dev/null +++ b/libs/core/src/lib/config/preferred-terminology-file.spec.ts @@ -0,0 +1,341 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { LingoTrackerConfig } from '../../config/lingo-tracker-config'; +import { + clearPreferredTerminologyCache, + DEFAULT_PREFERRED_TERMINOLOGY_FILENAME, + loadPreferredTerminology, + PreferredTerminologyValidationError, + resolvePreferredTerminologyFilePath, + writePreferredTerminology, +} from './preferred-terminology-file'; + +const baseConfig = (overrides: Partial = {}): LingoTrackerConfig => ({ + exportFolder: 'dist/lingo-export', + importFolder: 'dist/lingo-import', + baseLocale: 'en', + locales: ['en', 'es'], + collections: {}, + ...overrides, +}); + +describe('preferred-terminology-file', () => { + let cwd: string; + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'lingo-preferred-terminology-')); + clearPreferredTerminologyCache(); + }); + + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); + }); + + const write = (relativePath: string, contents: string): string => { + const filePath = join(cwd, relativePath); + writeFileSync(filePath, contents, 'utf8'); + return filePath; + }; + + describe('resolvePreferredTerminologyFilePath', () => { + it('falls back to the default filename when the config names no file', () => { + expect(resolvePreferredTerminologyFilePath(baseConfig(), cwd)).toBe( + resolve(cwd, DEFAULT_PREFERRED_TERMINOLOGY_FILENAME), + ); + }); + + it('resolves a relative pointer against the config directory', () => { + expect( + resolvePreferredTerminologyFilePath(baseConfig({ preferredTerminologyFile: 'config/terms.json' }), cwd), + ).toBe(join(cwd, 'config/terms.json')); + }); + + it('uses an absolute pointer as-is', () => { + expect( + resolvePreferredTerminologyFilePath(baseConfig({ preferredTerminologyFile: '/etc/terms.json' }), cwd), + ).toBe('/etc/terms.json'); + }); + + it('throws a descriptive error for a non-string pointer', () => { + expect(() => + resolvePreferredTerminologyFilePath(baseConfig({ preferredTerminologyFile: 42 as never }), cwd), + ).toThrow('"preferredTerminologyFile" in .lingo-tracker.json must be a string path (got number)'); + }); + }); + + describe('loadPreferredTerminology', () => { + it('returns an empty list, silently, when the default file is missing', () => { + const result = loadPreferredTerminology(baseConfig(), cwd); + + expect(result).toEqual({ rules: [], filePath: resolve(cwd, DEFAULT_PREFERRED_TERMINOLOGY_FILENAME) }); + }); + + it('warns, with an empty list, when an explicitly configured file is missing', () => { + const result = loadPreferredTerminology(baseConfig({ preferredTerminologyFile: 'absent.json' }), cwd); + + expect(result.rules).toEqual([]); + expect(result.error).toBeUndefined(); + expect(result.warning).toContain(join(cwd, 'absent.json')); + }); + + it('reads, normalizes, and keeps file order for a valid file', () => { + write( + DEFAULT_PREFERRED_TERMINOLOGY_FILENAME, + JSON.stringify([ + { discouraged: ' Wallet ', preferred: 'Account', reason: ' ' }, + { discouraged: 'Expenditure', preferred: 'Investment', reason: ' Brand voice ' }, + ]), + ); + + const result = loadPreferredTerminology(baseConfig(), cwd); + + expect(result.error).toBeUndefined(); + expect(result.warning).toBeUndefined(); + expect(result.rules).toEqual([ + { discouraged: 'Wallet', preferred: 'Account' }, + { discouraged: 'Expenditure', preferred: 'Investment', reason: 'Brand voice' }, + ]); + }); + + it('reads an explicitly configured file at an absolute path', () => { + const filePath = write('custom.json', '[{ "discouraged": "Expenditure", "preferred": "Investment" }]'); + + const result = loadPreferredTerminology(baseConfig({ preferredTerminologyFile: filePath }), '/elsewhere'); + + expect(result.filePath).toBe(filePath); + expect(result.rules).toEqual([{ discouraged: 'Expenditure', preferred: 'Investment' }]); + }); + + it('reports malformed JSON as an error with no rules', () => { + const filePath = write(DEFAULT_PREFERRED_TERMINOLOGY_FILENAME, '[{ "discouraged": '); + + const result = loadPreferredTerminology(baseConfig(), cwd); + + expect(result.rules).toEqual([]); + expect(result.error).toContain('not valid JSON'); + expect(result.error).toContain(filePath); + }); + + it('reports a pointer through a regular file as an unreadable file, without throwing', () => { + write('somefile.json', '[]'); + const config = baseConfig({ preferredTerminologyFile: 'somefile.json/rules.json' }); + + const result = loadPreferredTerminology(config, cwd); + + expect(result.rules).toEqual([]); + expect(result.warning).toBeUndefined(); + expect(result.error).toContain('cannot be read'); + expect(result.error).toContain(join(cwd, 'somefile.json/rules.json')); + expect(result.error).toContain('ENOTDIR'); + }); + + it.each([ + ['a number', 42, 'number'], + ['a boolean', true, 'boolean'], + ['an object', {}, 'object'], + ['an array', [], 'array'], + ])('reports %s pointer as an error with no rules, without throwing', (_label, pointer, type) => { + const config = baseConfig({ preferredTerminologyFile: pointer as never }); + + const result = loadPreferredTerminology(config, cwd); + + expect(result.rules).toEqual([]); + expect(result.warning).toBeUndefined(); + expect(result.filePath).toBe(resolve(cwd, DEFAULT_PREFERRED_TERMINOLOGY_FILENAME)); + expect(result.error).toBe( + `"preferredTerminologyFile" in .lingo-tracker.json must be a string path (got ${type})`, + ); + }); + + it('treats a null pointer like an unset one', () => { + const config = baseConfig({ preferredTerminologyFile: null as never }); + + const result = loadPreferredTerminology(config, cwd); + + expect(result).toEqual({ rules: [], filePath: resolve(cwd, DEFAULT_PREFERRED_TERMINOLOGY_FILENAME) }); + }); + + it('reports an empty pointer, which resolves to the config directory, as an unreadable file', () => { + const result = loadPreferredTerminology(baseConfig({ preferredTerminologyFile: '' }), cwd); + + expect(result.rules).toEqual([]); + expect(result.error).toContain('cannot be read'); + expect(result.error).toContain('EISDIR'); + }); + + it('reports a directory at the file path as an unreadable file, not as invalid JSON', () => { + mkdirSync(join(cwd, DEFAULT_PREFERRED_TERMINOLOGY_FILENAME)); + + const result = loadPreferredTerminology(baseConfig(), cwd); + + expect(result.rules).toEqual([]); + expect(result.error).toContain('cannot be read'); + expect(result.error).not.toContain('not valid JSON'); + expect(result.error).toContain('EISDIR'); + }); + + it('reports a non-array payload as an error', () => { + write(DEFAULT_PREFERRED_TERMINOLOGY_FILENAME, '{ "rules": [] }'); + + const result = loadPreferredTerminology(baseConfig(), cwd); + + expect(result.rules).toEqual([]); + expect(result.error).toContain('must contain a JSON array of rules'); + }); + + it('reports a non-object element as an error', () => { + write( + DEFAULT_PREFERRED_TERMINOLOGY_FILENAME, + '[{ "discouraged": "Expenditure", "preferred": "Investment" }, 42]', + ); + + const result = loadPreferredTerminology(baseConfig(), cwd); + + expect(result.rules).toEqual([]); + expect(result.error).toContain('row 2 rule: Rule must be an object.'); + }); + + it('lists every invalid row in the error, including the file path', () => { + const filePath = write( + DEFAULT_PREFERRED_TERMINOLOGY_FILENAME, + JSON.stringify([ + { discouraged: 'Expenditure', preferred: 'Investment' }, + { discouraged: 'Email', preferred: 'email' }, + { discouraged: 'Wallet', preferred: '' }, + ]), + ); + + const result = loadPreferredTerminology(baseConfig(), cwd); + + expect(result.rules).toEqual([]); + expect(result.error).toContain(filePath); + expect(result.error).toContain('row 2 preferred:'); + expect(result.error).toContain('row 3 preferred: Preferred term is required.'); + }); + + it('does not cache a broken file, so a fix is picked up on the next load', () => { + write(DEFAULT_PREFERRED_TERMINOLOGY_FILENAME, 'not json'); + expect(loadPreferredTerminology(baseConfig(), cwd).error).toBeDefined(); + + write(DEFAULT_PREFERRED_TERMINOLOGY_FILENAME, '[{ "discouraged": "Expenditure", "preferred": "Investment" }]'); + + expect(loadPreferredTerminology(baseConfig(), cwd).rules).toHaveLength(1); + }); + + it('hands back copies so mutating a result cannot poison the cache', () => { + write(DEFAULT_PREFERRED_TERMINOLOGY_FILENAME, '[{ "discouraged": "Expenditure", "preferred": "Investment" }]'); + const first = loadPreferredTerminology(baseConfig(), cwd); + first.rules[0].preferred = 'Mutated'; + first.rules.push({ discouraged: 'Extra', preferred: 'Row' }); + + expect(loadPreferredTerminology(baseConfig(), cwd).rules).toEqual([ + { discouraged: 'Expenditure', preferred: 'Investment' }, + ]); + }); + + it('picks up an external edit made after a cached load', () => { + const filePath = write( + DEFAULT_PREFERRED_TERMINOLOGY_FILENAME, + '[{ "discouraged": "Expenditure", "preferred": "Investment" }]', + ); + expect(loadPreferredTerminology(baseConfig(), cwd).rules).toHaveLength(1); + const before = statSync(filePath); + + writeFileSync( + filePath, + '[{ "discouraged": "Wallet", "preferred": "Account" }, { "discouraged": "Client", "preferred": "Customer" }]', + 'utf8', + ); + const bumped = new Date(before.mtimeMs + 5000); + utimesSync(filePath, bumped, bumped); + + expect(loadPreferredTerminology(baseConfig(), cwd).rules).toEqual([ + { discouraged: 'Wallet', preferred: 'Account' }, + { discouraged: 'Client', preferred: 'Customer' }, + ]); + }); + + it('returns the missing-file result when the file is deleted after a cached load', () => { + const config = baseConfig({ preferredTerminologyFile: 'terms.json' }); + const filePath = write('terms.json', '[{ "discouraged": "Expenditure", "preferred": "Investment" }]'); + expect(loadPreferredTerminology(config, cwd).rules).toHaveLength(1); + + unlinkSync(filePath); + + const result = loadPreferredTerminology(config, cwd); + expect(result.rules).toEqual([]); + expect(result.warning).toContain(filePath); + }); + }); + + describe('writePreferredTerminology', () => { + it('writes sorted, normalized, 2-space JSON with a trailing newline and no empty reason', () => { + const filePath = join(cwd, DEFAULT_PREFERRED_TERMINOLOGY_FILENAME); + + writePreferredTerminology(filePath, [ + { discouraged: 'wallet', preferred: 'Account', reason: ' ' }, + { discouraged: ' Expenditure ', preferred: 'Investment', reason: ' Brand voice ' }, + { discouraged: 'Client', preferred: 'Customer' }, + ]); + + expect(readFileSync(filePath, 'utf8')).toBe( + `${JSON.stringify( + [ + { discouraged: 'Client', preferred: 'Customer' }, + { discouraged: 'Expenditure', preferred: 'Investment', reason: 'Brand voice' }, + { discouraged: 'wallet', preferred: 'Account' }, + ], + null, + 2, + )}\n`, + ); + }); + + it('throws a validation error carrying the per-row errors, leaving the file untouched', () => { + const filePath = write(DEFAULT_PREFERRED_TERMINOLOGY_FILENAME, '[]\n'); + + let thrown: unknown; + try { + writePreferredTerminology(filePath, [ + { discouraged: 'Expenditure', preferred: 'Investment' }, + { discouraged: 'Investment', preferred: 'Capital' }, + ]); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(PreferredTerminologyValidationError); + const errors = thrown instanceof PreferredTerminologyValidationError ? thrown.errors : []; + expect(errors).toEqual([expect.objectContaining({ index: 0, field: 'preferred', code: 'chain' })]); + expect(readFileSync(filePath, 'utf8')).toBe('[]\n'); + }); + + it('throws when the parent directory is missing', () => { + expect(() => + writePreferredTerminology(join(cwd, 'nested/terms.json'), [ + { discouraged: 'Expenditure', preferred: 'Investment' }, + ]), + ).toThrow('directory does not exist'); + }); + + it('refreshes the cache so a following load sees the new rules', () => { + const filePath = write( + DEFAULT_PREFERRED_TERMINOLOGY_FILENAME, + '[{ "discouraged": "Expenditure", "preferred": "Investment" }]', + ); + expect(loadPreferredTerminology(baseConfig(), cwd).rules).toHaveLength(1); + + writePreferredTerminology(filePath, [ + { discouraged: 'Wallet', preferred: 'Account' }, + { discouraged: 'Client', preferred: 'Customer' }, + ]); + + expect(loadPreferredTerminology(baseConfig(), cwd).rules).toEqual([ + { discouraged: 'Client', preferred: 'Customer' }, + { discouraged: 'Wallet', preferred: 'Account' }, + ]); + }); + }); +}); diff --git a/libs/core/src/lib/config/preferred-terminology-file.ts b/libs/core/src/lib/config/preferred-terminology-file.ts new file mode 100644 index 0000000..a0dca34 --- /dev/null +++ b/libs/core/src/lib/config/preferred-terminology-file.ts @@ -0,0 +1,262 @@ +import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { dirname, isAbsolute, resolve } from 'node:path'; +import { + normalizePreferredTermRules, + type PreferredTermRule, + type PreferredTermRuleError, + sortPreferredTermRules, + validatePreferredTermRules, +} from '@simoncodes-ca/domain'; +import type { LingoTrackerConfig } from '../../config/lingo-tracker-config'; + +/** + * Default location of the preferred-terminology file, resolved against the directory + * holding `.lingo-tracker.json`. Used whenever the config has no explicit + * `preferredTerminologyFile` pointer. There is one global file; collections cannot + * override it. + */ +export const DEFAULT_PREFERRED_TERMINOLOGY_FILENAME = '.lingo-tracker-preferred-terminology.json'; + +/** Outcome of reading the preferred-terminology file. Never thrown; problems are reported in-band. */ +export interface LoadPreferredTerminologyResult { + /** Normalized rules in file order; empty when the file is absent or broken. */ + rules: PreferredTermRule[]; + /** Absolute path of the file, whether or not it exists yet. */ + filePath: string; + /** Set when the file exists but cannot be used: unreadable, malformed JSON, wrong shape, or invalid rules. */ + error?: string; + /** Set when an explicitly configured file does not exist. */ + warning?: string; +} + +/** Thrown by `writePreferredTerminology` when the rule list fails validation. The file is left untouched. */ +export class PreferredTerminologyValidationError extends Error { + readonly errors: PreferredTermRuleError[]; + + constructor(errors: PreferredTermRuleError[]) { + super(`Invalid preferred terminology rules: ${formatRuleErrors(errors)}`); + this.name = 'PreferredTerminologyValidationError'; + this.errors = errors; + } +} + +/** File identity recorded alongside cached rules; a change in either field means the file was edited. */ +interface FileStamp { + mtimeMs: number; + size: number; +} + +interface CacheEntry { + rules: PreferredTermRule[]; + stamp: FileStamp; +} + +/** + * In-process cache keyed by absolute file path. Only successful loads are cached, and + * writes refresh it. Each hit is revalidated against the file's `mtimeMs` and `size`, so + * a long-lived process (the API server) picks up hand edits, `git pull`, or deletion on + * the next load instead of serving stale rules until restart. + */ +const cache = new Map(); + +/** Drops every cached preferred-terminology file. Exported for tests and for callers that write out-of-band. */ +export function clearPreferredTerminologyCache(): void { + cache.clear(); +} + +/** + * Absolute path of the preferred-terminology file: the config's + * `preferredTerminologyFile` pointer resolved against `cwd` (the directory holding the + * config file), falling back to the default filename. Absolute pointers are used as-is. + * + * Throws a descriptive `Error` when the pointer is neither a string nor unset (`null` + * counts as unset): `.lingo-tracker.json` is hand-edited and not schema-validated. + */ +export function resolvePreferredTerminologyFilePath( + config: Pick, + cwd: string = process.cwd(), +): string { + const pointerError = invalidPointerError(config); + if (pointerError) { + throw new Error(pointerError); + } + const pointer = config.preferredTerminologyFile ?? DEFAULT_PREFERRED_TERMINOLOGY_FILENAME; + return isAbsolute(pointer) ? pointer : resolve(cwd, pointer); +} + +/** + * Reads the preferred-terminology file: a bare JSON array of `{ discouraged, preferred, + * reason? }` rules, normalized and kept in file order. + * + * Never throws. A missing file at the default path reads as an empty list — the normal + * state before any rule has been added. A missing file at an explicit pointer also + * reads as empty but sets `warning`, since a pointer at nothing is usually a typo. + * A non-string pointer, malformed JSON, a non-array payload, or any rule failing + * validation sets `error` and returns no rules: terminology checks are advisory, so + * callers warn and skip them rather than abort, except `validate`, which reports a + * broken file as a failure. + */ +export function loadPreferredTerminology( + config: Pick, + cwd: string = process.cwd(), +): LoadPreferredTerminologyResult { + const pointerError = invalidPointerError(config); + if (pointerError) { + return { rules: [], filePath: resolve(cwd, DEFAULT_PREFERRED_TERMINOLOGY_FILENAME), error: pointerError }; + } + const filePath = resolvePreferredTerminologyFilePath(config, cwd); + + let stamp: FileStamp | undefined; + try { + stamp = readStamp(filePath); + } catch (error) { + cache.delete(filePath); + return { rules: [], filePath, error: unreadableFileError(filePath, error) }; + } + + const cached = cache.get(filePath); + if (cached) { + if (stamp && sameStamp(cached.stamp, stamp)) { + return { rules: cloneRules(cached.rules), filePath }; + } + cache.delete(filePath); + } + + if (!stamp) { + if (config.preferredTerminologyFile != null) { + return { + rules: [], + filePath, + warning: `Preferred terminology file not found: ${filePath}. Treating as an empty list.`, + }; + } + return { rules: [], filePath }; + } + + let contents: string; + try { + contents = readFileSync(filePath, 'utf8'); + } catch (error) { + return { rules: [], filePath, error: unreadableFileError(filePath, error) }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(contents); + } catch (error) { + return { + rules: [], + filePath, + error: `Preferred terminology file is not valid JSON: ${filePath} (${errorDetail(error)})`, + }; + } + + if (!Array.isArray(parsed)) { + return { + rules: [], + filePath, + error: `Preferred terminology file must contain a JSON array of rules: ${filePath}`, + }; + } + + const errors = validatePreferredTermRules(parsed); + if (errors.length > 0) { + return { + rules: [], + filePath, + error: `Preferred terminology file has invalid rules: ${filePath} (${formatRuleErrors(errors)})`, + }; + } + + const rules = normalizePreferredTermRules(parsed as PreferredTermRule[]); + cache.set(filePath, { rules, stamp }); + return { rules: cloneRules(rules), filePath }; +} + +/** + * Writes the preferred-terminology file: normalized, sorted by discouraged term, 2-space + * JSON with a trailing newline. Sorting keeps an added rule to a small diff regardless + * of where it lands. An empty `reason` is dropped rather than written as `""`. + * + * Throws `PreferredTerminologyValidationError` (with the per-row errors attached) when + * the rules fail validation, and a plain `Error` when the parent directory is missing; + * in both cases the file is left untouched. The file is created when absent. + */ +export function writePreferredTerminology(filePath: string, rules: readonly PreferredTermRule[]): void { + // Validate before normalizing: rules may arrive from an untyped source (the API), and + // normalizing trims fields that might not be strings. Validation trims on its own. + const errors = validatePreferredTermRules(rules); + if (errors.length > 0) { + throw new PreferredTerminologyValidationError(errors); + } + + const parent = dirname(filePath); + if (!existsSync(parent)) { + throw new Error(`Cannot write preferred terminology file — directory does not exist: ${parent}`); + } + + const sorted = sortPreferredTermRules(normalizePreferredTermRules(rules)); + writeFileSync(filePath, `${JSON.stringify(sorted, null, 2)}\n`, 'utf8'); + // The write succeeded; failing to stat it afterwards only costs the cache entry. + let stamp: FileStamp | undefined; + try { + stamp = readStamp(filePath); + } catch { + stamp = undefined; + } + if (stamp) { + cache.set(filePath, { rules: sorted, stamp }); + } else { + cache.delete(filePath); + } +} + +/** Error message for a `preferredTerminologyFile` that is set but not a string; `undefined` when usable. */ +function invalidPointerError(config: Pick): string | undefined { + const pointer: unknown = config.preferredTerminologyFile; + if (pointer === undefined || pointer === null || typeof pointer === 'string') { + return undefined; + } + const type = Array.isArray(pointer) ? 'array' : typeof pointer; + return `"preferredTerminologyFile" in .lingo-tracker.json must be a string path (got ${type})`; +} + +/** One `row N field: message` entry per error, rows 1-based, joined into a single line. */ +function formatRuleErrors(errors: readonly PreferredTermRuleError[]): string { + return errors.map((error) => `row ${error.index + 1} ${error.field}: ${error.message}`).join('; '); +} + +function unreadableFileError(filePath: string, error: unknown): string { + return `Preferred terminology file cannot be read: ${filePath} (${errorDetail(error)})`; +} + +function errorDetail(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * `mtimeMs` and `size` of the file, or `undefined` when it does not exist. Throws on any + * other stat failure (EACCES, ENOTDIR, ELOOP, ...); a pointer through a regular file is a + * misconfiguration, not a missing file. + */ +function readStamp(filePath: string): FileStamp | undefined { + // Not `throwIfNoEntry: false`: Node folds ENOTDIR into "no entry" there too. + try { + const stats = statSync(filePath); + return { mtimeMs: stats.mtimeMs, size: stats.size }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return undefined; + } + throw error; + } +} + +function sameStamp(a: FileStamp, b: FileStamp): boolean { + return a.mtimeMs === b.mtimeMs && a.size === b.size; +} + +/** Copies rules so a caller mutating the result cannot poison the cache. */ +function cloneRules(rules: readonly PreferredTermRule[]): PreferredTermRule[] { + return rules.map((rule) => ({ ...rule })); +} diff --git a/libs/core/src/lib/import/import-preferred-terminology.spec.ts b/libs/core/src/lib/import/import-preferred-terminology.spec.ts new file mode 100644 index 0000000..73a221e --- /dev/null +++ b/libs/core/src/lib/import/import-preferred-terminology.spec.ts @@ -0,0 +1,195 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { PreferredTermRule } from '@simoncodes-ca/domain'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { importFromJson } from './import-from-json'; +import { importFromXliff } from './import-from-xliff'; +import type { ImportOptions } from './types'; + +const rules: PreferredTermRule[] = [ + { discouraged: 'Expenditure', preferred: 'Investment', reason: 'Finance style guide' }, + { discouraged: 'e-mail', preferred: 'email' }, +]; + +const xliff = (targetLanguage: string, units: Record): string => + ` + + + +${Object.entries(units) + .map( + ([id, { source, target }]) => + ` ${source}${target}`, + ) + .join('\n')} + + +`; + +describe('preferred terminology on import', () => { + let projectDir: string; + let translationsFolder: string; + + beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), 'lingo-import-terminology-')); + translationsFolder = join(projectDir, 'translations'); + mkdirSync(translationsFolder, { recursive: true }); + vi.spyOn(process, 'cwd').mockReturnValue(projectDir); + }); + + afterEach(() => { + vi.restoreAllMocks(); + rmSync(projectDir, { recursive: true, force: true }); + }); + + const writeSource = (name: string, content: string): string => { + const filePath = join(projectDir, name); + writeFileSync(filePath, content, 'utf8'); + return filePath; + }; + + const baseOptions = (source: string, overrides: Partial = {}): ImportOptions => ({ + source, + locale: 'en', + baseLocale: 'en', + strategy: 'migration', + preferredTerminology: rules, + ...overrides, + }); + + const seedExisting = (): void => { + const folder = join(translationsFolder, 'budget'); + mkdirSync(folder, { recursive: true }); + writeFileSync( + join(folder, 'resource_entries.json'), + JSON.stringify({ title: { source: 'Capital expenditure', es: 'Gasto de capital' } }), + ); + writeFileSync(join(folder, 'tracker_meta.json'), JSON.stringify({ title: { en: { checksum: 'x' } } })); + }; + + it('warns once per rule for each base-locale JSON value it writes, and still imports them', () => { + const source = writeSource( + 'en.json', + JSON.stringify({ + 'budget.title': 'Expenditure summary: expenditure by month', + 'budget.contact': 'Send the expenditure report by e-mail', + 'budget.clean': 'Investment summary', + }), + ); + + const result = importFromJson(translationsFolder, baseOptions(source)); + + expect(result.warnings).toEqual( + expect.arrayContaining([ + 'Preferred terminology: key "budget.title" — consider "Investment" instead of "Expenditure". Finance style guide', + 'Preferred terminology: key "budget.contact" — consider "Investment" instead of "Expenditure". Finance style guide', + 'Preferred terminology: key "budget.contact" — consider "email" instead of "e-mail"', + ]), + ); + expect(result.warnings.filter((w) => w.startsWith('Preferred terminology'))).toHaveLength(3); + expect(result.resourcesCreated).toBe(3); + expect(result.resourcesFailed).toBe(0); + expect(result.errors).toEqual([]); + }); + + it('warns about an updated base value', () => { + seedExisting(); + const source = writeSource('en.json', JSON.stringify({ 'budget.title': 'Operating expenditure' })); + + const result = importFromJson(translationsFolder, baseOptions(source)); + + expect(result.warnings).toContain( + 'Preferred terminology: key "budget.title" — consider "Investment" instead of "Expenditure". Finance style guide', + ); + const entries = JSON.parse(readFileSync(join(translationsFolder, 'budget', 'resource_entries.json'), 'utf8')); + expect(entries.title.source).toBe('Operating expenditure'); + }); + + it('warns during a dry run without writing anything', () => { + const source = writeSource('en.json', JSON.stringify({ 'budget.title': 'Expenditure' })); + + const result = importFromJson(translationsFolder, baseOptions(source, { dryRun: true })); + + expect(result.warnings.filter((w) => w.startsWith('Preferred terminology'))).toHaveLength(1); + expect(result.filesModified).toEqual([]); + }); + + it('never warns on a target-locale import', () => { + seedExisting(); + const source = writeSource('es.json', JSON.stringify({ 'budget.title': 'Expenditure de capital' })); + + const result = importFromJson( + translationsFolder, + baseOptions(source, { locale: 'es', strategy: 'translation-service' }), + ); + + expect(result.warnings.some((w) => w.startsWith('Preferred terminology'))).toBe(false); + expect(result.resourcesUpdated).toBe(1); + }); + + it('skips the check when no rules are given', () => { + const source = writeSource('en.json', JSON.stringify({ 'budget.title': 'Expenditure' })); + + const result = importFromJson(translationsFolder, baseOptions(source, { preferredTerminology: undefined })); + + expect(result.warnings.some((w) => w.startsWith('Preferred terminology'))).toBe(false); + }); + + it('warns for base-locale XLIFF values', async () => { + const source = writeSource( + 'en.xliff', + xliff('en', { 'budget.title': { source: 'Expenditure', target: 'Capital expenditure' } }), + ); + + const result = await importFromXliff(translationsFolder, baseOptions(source)); + + expect(result.warnings).toContain( + 'Preferred terminology: key "budget.title" — consider "Investment" instead of "Expenditure". Finance style guide', + ); + expect(result.resourcesCreated).toBe(1); + }); + + describe("collection whose base locale differs from the project's", () => { + it('warns on an import into that base locale', () => { + const source = writeSource('fr.json', JSON.stringify({ 'budget.title': 'Expenditure du mois' })); + + const result = importFromJson(translationsFolder, baseOptions(source, { locale: 'fr', baseLocale: 'fr' })); + + expect(result.warnings).toContain( + 'Preferred terminology: key "budget.title" — consider "Investment" instead of "Expenditure". Finance style guide', + ); + const entries = JSON.parse(readFileSync(join(translationsFolder, 'budget', 'resource_entries.json'), 'utf8')); + expect(entries.title.source).toBe('Expenditure du mois'); + }); + + it("treats the project base locale as a target locale and doesn't warn", () => { + seedExisting(); + const source = writeSource('en.json', JSON.stringify({ 'budget.title': 'Capital expenditure' })); + + const result = importFromJson( + translationsFolder, + baseOptions(source, { locale: 'en', baseLocale: 'fr', strategy: 'translation-service' }), + ); + + expect(result.warnings.some((w) => w.startsWith('Preferred terminology'))).toBe(false); + const entries = JSON.parse(readFileSync(join(translationsFolder, 'budget', 'resource_entries.json'), 'utf8')); + expect(entries.title.en).toBe('Capital expenditure'); + }); + }); + + it('never warns on a target-locale XLIFF import', async () => { + seedExisting(); + const source = writeSource( + 'es.xliff', + xliff('es', { 'budget.title': { source: 'Capital expenditure', target: 'Expenditure de capital' } }), + ); + + const result = await importFromXliff( + translationsFolder, + baseOptions(source, { locale: 'es', strategy: 'translation-service' }), + ); + + expect(result.warnings.some((w) => w.startsWith('Preferred terminology'))).toBe(false); + }); +}); diff --git a/libs/core/src/lib/import/process-resource-group.ts b/libs/core/src/lib/import/process-resource-group.ts index dd95d76..2519150 100644 --- a/libs/core/src/lib/import/process-resource-group.ts +++ b/libs/core/src/lib/import/process-resource-group.ts @@ -1,12 +1,18 @@ import { existsSync } from 'node:fs'; -import { readJsonFile, writeJsonFile } from '../file-io/json-file-operations'; -import type { ImportOptions, ImportChange, ImportedResource } from './types'; +import { + findPreferredTermFindings, + findProtectedTermViolations, + type LocaleMetadata, + type TranslationStatus, +} from '@simoncodes-ca/domain'; +import { calculateChecksum } from '../../resource/checksum'; import type { ResourceEntries, ResourceEntry } from '../../resource/resource-entry'; import type { TrackerMetadata } from '../../resource/tracker-metadata'; -import { calculateChecksum } from '../../resource/checksum'; -import { findProtectedTermViolations, type LocaleMetadata, type TranslationStatus } from '@simoncodes-ca/domain'; +import { readJsonFile, writeJsonFile } from '../file-io/json-file-operations'; +import { describePreferredTermRule } from '../validate/validate-terminology'; +import { determineNewResourceStatus, determineUpdatedResourceStatus, shouldUseSourceStatus } from './determine-status'; import type { ResourceGroup } from './resource-grouping'; -import { shouldUseSourceStatus, determineNewResourceStatus, determineUpdatedResourceStatus } from './determine-status'; +import type { ImportChange, ImportedResource, ImportOptions } from './types'; // --------------------------------------------------------------------------- // Internal context shared across all handlers in one processResourceGroup call @@ -262,6 +268,25 @@ function handleUnchangedTargetLocaleValue( }; } +// --------------------------------------------------------------------------- +// Preferred terminology (base-locale imports only) +// --------------------------------------------------------------------------- + +/** + * Adds one warning per discouraged term in a base value this import wrote, or would + * write in a dry run. Advisory: the value is imported regardless. + */ +function warnAboutPreferredTerminology(change: ImportChange, options: ImportOptions, warnings: string[]): void { + const rules = options.preferredTerminology ?? []; + if (rules.length === 0 || change.newValue === undefined) return; + if (change.type === 'failed' || change.type === 'skipped') return; + + for (const { rule } of findPreferredTermFindings(change.newValue, rules)) { + const reason = rule.reason ? `. ${rule.reason}` : ''; + warnings.push(`Preferred terminology: key "${change.key}" — ${describePreferredTermRule(rule)}${reason}`); + } +} + // --------------------------------------------------------------------------- // Public entry point // --------------------------------------------------------------------------- @@ -298,7 +323,8 @@ function handleUnchangedTargetLocaleValue( * @param dryRun - When true, performs all operations except file writes * @param isBaseLocaleImport - Whether this is a base locale import (migration strategy only) * @param filesModified - Set that accumulates paths of all modified files (for summary reporting) - * @param warnings - Array that accumulates non-fatal warnings (e.g., base value mismatches) + * @param warnings - Array that accumulates non-fatal warnings (e.g., base value mismatches, and + * preferred-terminology findings on base-locale imports) * @param errors - Optional array that accumulates fatal error messages (e.g., protected-term violations) * @returns Array of ImportChange objects describing all changes made to resources in this group */ @@ -346,12 +372,16 @@ export function processResourceGroup( }); continue; } - changes.push(handleNewResource(ctx, resource, entryKey, isBaseLocaleImport)); + const created = handleNewResource(ctx, resource, entryKey, isBaseLocaleImport); + if (isBaseLocaleImport) warnAboutPreferredTerminology(created, options, warnings); + changes.push(created); continue; } if (isBaseLocaleImport) { - changes.push(handleBaseLocaleUpdate(ctx, resource, entryKey)); + const updated = handleBaseLocaleUpdate(ctx, resource, entryKey); + warnAboutPreferredTerminology(updated, options, warnings); + changes.push(updated); continue; } diff --git a/libs/core/src/lib/import/types.ts b/libs/core/src/lib/import/types.ts index 9902769..1137acb 100644 --- a/libs/core/src/lib/import/types.ts +++ b/libs/core/src/lib/import/types.ts @@ -1,4 +1,4 @@ -import type { TranslationStatus } from '@simoncodes-ca/domain'; +import type { PreferredTermRule, TranslationStatus } from '@simoncodes-ca/domain'; /** * Supported import formats @@ -48,6 +48,13 @@ export interface ImportOptions { * incoming value altered it is skipped and reported as failed. Unset skips the check. */ protectedTerms?: string[]; + /** + * Preferred-terminology rules. On a base-locale import, every value written (or, + * in a dry run, that would be written) is scanned, and each discouraged term found + * adds one entry to `warnings`. Advisory only: nothing is skipped or failed. + * Ignored for target-locale imports. Unset or empty skips the check. + */ + preferredTerminology?: readonly PreferredTermRule[]; /** Callbacks */ onProgress?: (message: string) => void; diff --git a/libs/core/src/lib/validate/generate-validation-summary.spec.ts b/libs/core/src/lib/validate/generate-validation-summary.spec.ts index 2f4d85b..e919361 100644 --- a/libs/core/src/lib/validate/generate-validation-summary.spec.ts +++ b/libs/core/src/lib/validate/generate-validation-summary.spec.ts @@ -1,6 +1,11 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { generateValidationSummary } from './generate-validation-summary'; -import type { ResourceValidationResult, ValidationOptions, ResourceValidationDetail } from './types'; +import type { + ResourceValidationDetail, + ResourceValidationResult, + TerminologyValidationDetail, + ValidationOptions, +} from './types'; describe('generateValidationSummary', () => { const defaultOptions: ValidationOptions = { @@ -867,6 +872,96 @@ describe('generateValidationSummary', () => { expect(skippedIdx).toBeLessThan(collectionsIdx); }); }); + + describe('preferred terminology', () => { + const baseResult: ResourceValidationResult = { + totalResourcesValidated: 2, + totalUniqueKeys: 1, + localesValidated: 2, + collectionsValidated: 1, + statusCounts: { new: 0, translated: 0, stale: 0, verified: 2 }, + failures: [], + warnings: [], + successes: createResourceDetails('verified', 2, 'es', 'main'), + passed: true, + }; + const rules = [{ discouraged: 'Expenditure', preferred: 'Investment' }]; + const options: ValidationOptions = { + allowTranslated: false, + terminology: { rules, baseLocaleByCollection: { main: 'en' } }, + }; + + const finding = (key: string, reason?: string): TerminologyValidationDetail => ({ + key, + collection: 'main', + locale: 'en', + discouraged: 'Expenditure', + preferred: 'Investment', + ...(reason ? { reason } : {}), + message: 'consider "Investment" instead of "Expenditure"', + }); + + it('lists each finding with its reason on its own line', () => { + const summary = generateValidationSummary( + { + ...baseResult, + terminology: { + warnings: [finding('budget.title', 'Finance style guide'), finding('budget.subtitle')], + valuesChecked: 1, + }, + }, + options, + ); + + expect(summary).toContain('⚠️ Preferred terminology warnings (2):'); + expect(summary).toContain( + ' [main] budget.title: consider "Investment" instead of "Expenditure"\n Finance style guide', + ); + expect(summary).toContain(' [main] budget.subtitle: consider "Investment" instead of "Expenditure"'); + expect(summary).toContain('Terminology Values Scanned: 1'); + expect(summary).toContain('Total Preferred Terminology Warnings: 2'); + expect(summary).toContain('✅ Validation passed with warnings.'); + }); + + it('truncates a long list of findings', () => { + const warnings = Array.from({ length: 105 }, (_, i) => finding(`key.${i}`)); + const summary = generateValidationSummary( + { ...baseResult, terminology: { warnings, valuesChecked: 105 } }, + options, + ); + + expect(summary).toContain('[main] key.99:'); + expect(summary).not.toContain('[main] key.100:'); + expect(summary).toContain(' ... and 5 more'); + }); + + it('shows a load error as a failure', () => { + const summary = generateValidationSummary( + { + ...baseResult, + passed: false, + terminology: { warnings: [], configError: 'Preferred terminology file is not valid JSON', valuesChecked: 0 }, + }, + { allowTranslated: false, terminology: { rules: [], loadError: 'x', baseLocaleByCollection: {} } }, + ); + + expect(summary).toContain('❌ Preferred terminology file error:'); + expect(summary).toContain(' Preferred terminology file is not valid JSON'); + expect(summary).toContain('Preferred Terminology File: failed to load'); + expect(summary).not.toContain('Terminology Values Scanned'); + expect(summary).toContain('❌ Validation failed.'); + }); + + it('adds nothing when there are no rules and no load error', () => { + const summary = generateValidationSummary( + { ...baseResult, terminology: { warnings: [], valuesChecked: 0 } }, + { allowTranslated: false, terminology: { rules: [], baseLocaleByCollection: {} } }, + ); + + expect(summary).not.toMatch(/terminology/i); + expect(summary).toContain('✅ Validation passed successfully!'); + }); + }); }); /** diff --git a/libs/core/src/lib/validate/generate-validation-summary.ts b/libs/core/src/lib/validate/generate-validation-summary.ts index 7d955d3..329d36a 100644 --- a/libs/core/src/lib/validate/generate-validation-summary.ts +++ b/libs/core/src/lib/validate/generate-validation-summary.ts @@ -1,9 +1,10 @@ import type { - ResourceValidationResult, - ValidationOptions, - ResourceValidationDetail, IcuValidationDetail, IcuValidationResult, + ResourceValidationDetail, + ResourceValidationResult, + TerminologyValidationResult, + ValidationOptions, } from './types'; /** @@ -71,6 +72,9 @@ export function generateValidationSummary(result: ResourceValidationResult, opti ), ); } + if (result.terminology) { + sections.push(...buildTerminologySections(result.terminology)); + } sections.push(buildFooterSection(result, options)); return sections.join('\n\n'); @@ -122,6 +126,12 @@ function buildStatisticsSection(result: ResourceValidationResult, options: Valid lines.push(` Placeholders Compared: ${result.placeholders.valuesChecked}`); } + // Suppressed when there were no rules, where a count of 0 would read as + // "nothing used a discouraged term" rather than "nothing was scanned". + if (result.terminology && result.terminology.configError === undefined && options.terminology?.rules.length) { + lines.push(` Terminology Values Scanned: ${result.terminology.valuesChecked}`); + } + return lines.join('\n'); } @@ -187,6 +197,54 @@ function buildDetailSection(heading: string, details: readonly ExplainedDetail[] return lines.join('\n').trimEnd(); } +/** + * Builds the sections describing the preferred-terminology pass. + * + * An unreadable rule file is a failure and gets its own section. Findings are + * advisory and listed flat rather than by locale — only base-locale values are + * scanned, so every finding would share one locale per collection anyway. Both + * sections are omitted when empty, so a project without rules sees nothing. + * + * @param terminology - The terminology pass result + * @returns Zero or more formatted sections + * @internal + */ +function buildTerminologySections(terminology: TerminologyValidationResult): string[] { + const sections: string[] = []; + + if (terminology.configError !== undefined) { + sections.push( + [ + '❌ Preferred terminology file error:', + '─'.repeat(50), + ` ${terminology.configError}`, + ' No value was checked for preferred terminology. Fix the file, or', + ' remove it, and run validate again.', + ].join('\n'), + ); + } + + if (terminology.warnings.length > 0) { + const lines = [`⚠️ Preferred terminology warnings (${terminology.warnings.length}):`, '─'.repeat(50)]; + + for (const warning of terminology.warnings.slice(0, MAX_RESOURCES_TO_DISPLAY)) { + lines.push(` [${warning.collection}] ${warning.key}: ${warning.message}`); + if (warning.reason) { + lines.push(` ${warning.reason}`); + } + } + + const remaining = terminology.warnings.length - MAX_RESOURCES_TO_DISPLAY; + if (remaining > 0) { + lines.push(` ... and ${remaining} more`); + } + + sections.push(lines.join('\n')); + } + + return sections; +} + /** * Builds the notice for locales whose values could not be compiled at all. * @@ -330,15 +388,22 @@ function buildFooterSection(result: ResourceValidationResult, options: Validatio if (result.placeholders && result.placeholders.failures.length > 0) { lines.push(` Total Placeholder Failures: ${result.placeholders.failures.length}`); } + if (result.terminology?.configError !== undefined) { + lines.push(' Preferred Terminology File: failed to load'); + } if (options.allowTranslated && result.warnings.length > 0) { lines.push(` Total Warnings: ${result.warnings.length}`); } + const terminologyWarnings = result.terminology?.warnings.length ?? 0; + if (terminologyWarnings > 0) { + lines.push(` Total Preferred Terminology Warnings: ${terminologyWarnings}`); + } lines.push(` Total Successes: ${result.successes.length}`); // Final verdict lines.push(''); if (result.passed) { - if (result.warnings.length > 0) { + if (result.warnings.length > 0 || terminologyWarnings > 0) { lines.push('✅ Validation passed with warnings.'); } else { lines.push('✅ Validation passed successfully!'); diff --git a/libs/core/src/lib/validate/index.ts b/libs/core/src/lib/validate/index.ts index fb33546..edc0e34 100644 --- a/libs/core/src/lib/validate/index.ts +++ b/libs/core/src/lib/validate/index.ts @@ -1,5 +1,6 @@ -export * from './types'; -export * from './validate-resources'; export * from './generate-validation-summary'; +export * from './types'; export * from './validate-icu'; export * from './validate-placeholders'; +export * from './validate-resources'; +export * from './validate-terminology'; diff --git a/libs/core/src/lib/validate/types.ts b/libs/core/src/lib/validate/types.ts index 3aaa8d8..6cf7429 100644 --- a/libs/core/src/lib/validate/types.ts +++ b/libs/core/src/lib/validate/types.ts @@ -1,4 +1,4 @@ -import type { TranslationStatus } from '@simoncodes-ca/domain'; +import type { PreferredTermRule, TranslationStatus } from '@simoncodes-ca/domain'; /** * Options for configuring resource validation behavior. @@ -42,6 +42,103 @@ export interface ValidationOptions { * Omit to skip the check. */ readonly placeholders?: PlaceholderValidationOptions; + + /** + * When present, base-locale values are scanned for discouraged terms from the + * preferred-terminology file. + * + * A fourth question, and the only advisory one: a finding suggests better + * wording and never fails validation. A rule file that could not be loaded + * does fail it, since every check it should have run was silently skipped. + * + * The caller loads the rules; validation never reads the file itself. + * Omit to skip the check. + */ + readonly terminology?: TerminologyValidationOptions; +} + +/** + * Options controlling the preferred-terminology pass. + */ +export interface TerminologyValidationOptions { + /** + * Rules to scan for. Empty when the file is absent, or when it failed to load. + */ + readonly rules: readonly PreferredTermRule[]; + + /** + * Why the rule file could not be loaded, when it could not. Reported as a + * failure: a broken file means no value was checked. + */ + readonly loadError?: string; + + /** + * Effective base locale of each collection, by collection name. Findings are + * reported under this locale; a collection missing from the map is reported + * under an empty locale. + */ + readonly baseLocaleByCollection: Readonly>; +} + +/** + * A base-locale value using a discouraged term. One per collection, key, and rule, + * however many times the term occurs and however many target locales exist. + */ +export interface TerminologyValidationDetail { + /** + * The full dot-delimited key of the resource (e.g., 'common.buttons.ok'). + */ + readonly key: string; + + /** + * The collection this resource belongs to. + */ + readonly collection: string; + + /** + * The collection's base locale, whose value was scanned. + */ + readonly locale: string; + + /** + * The discouraged term, as spelled in the rule. + */ + readonly discouraged: string; + + /** + * The suggested replacement, as spelled in the rule. + */ + readonly preferred: string; + + /** + * Why the preferred term is preferred, when the rule says. + */ + readonly reason?: string; + + /** + * A single-line suggestion, e.g. `consider "Investment" instead of "Expenditure"`. + */ + readonly message: string; +} + +/** + * Outcome of the preferred-terminology pass. + */ +export interface TerminologyValidationResult { + /** + * Values using a discouraged term. Advisory: warnings never fail validation. + */ + readonly warnings: readonly TerminologyValidationDetail[]; + + /** + * Why the rule file could not be loaded, when it could not. Fails validation. + */ + readonly configError?: string; + + /** + * How many base-locale values were scanned. Zero when there were no rules to scan for. + */ + readonly valuesChecked: number; } /** @@ -287,10 +384,16 @@ export interface ResourceValidationResult { */ readonly placeholders?: PlaceholderValidationResult; + /** + * Outcome of the preferred-terminology pass, when one was requested. + * Undefined when terminology checking was not requested. + */ + readonly terminology?: TerminologyValidationResult; + /** * Whether the validation passed overall (no status failures, no ICU compile - * failures, and no placeholder mismatches). Note: warnings do not cause - * validation to fail. + * failures, no placeholder mismatches, and no unreadable terminology file). + * Note: warnings, including terminology findings, do not cause validation to fail. */ readonly passed: boolean; } diff --git a/libs/core/src/lib/validate/validate-resources.spec.ts b/libs/core/src/lib/validate/validate-resources.spec.ts index 4cb63fb..5964918 100644 --- a/libs/core/src/lib/validate/validate-resources.spec.ts +++ b/libs/core/src/lib/validate/validate-resources.spec.ts @@ -1,7 +1,7 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { validateResources } from './validate-resources'; -import * as exportCommon from '../export/export-common'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { LoadedResource } from '../export/export-common'; +import * as exportCommon from '../export/export-common'; +import { validateResources } from './validate-resources'; // Mock the export-common module vi.mock('../export/export-common', async () => { @@ -566,4 +566,74 @@ describe('validateResources', () => { expect(result.statusCounts.verified).toBe(1000); }); }); + + describe('preferred terminology', () => { + const rules = [{ discouraged: 'Expenditure', preferred: 'Investment' }]; + const collections = [ + { name: 'main', path: '/translations/main' }, + { name: 'legacy', path: '/translations/legacy' }, + ]; + + const verified = (overrides: Partial): LoadedResource => ({ + key: 'title', + fullKey: 'budget.title', + source: 'Capital expenditure', + translations: { es: 'Gasto de capital', fr: 'Dépenses en capital' }, + status: { es: 'verified', fr: 'verified' }, + collection: 'main', + ...overrides, + }); + + it('reports a finding once across every target locale and still passes', () => { + vi.mocked(exportCommon.loadResourcesFromCollections).mockReturnValue([verified({})]); + + const result = validateResources(collections, ['es', 'fr'], { + allowTranslated: false, + terminology: { rules, baseLocaleByCollection: { main: 'en', legacy: 'en' } }, + }); + + expect(result.passed).toBe(true); + expect(result.terminology?.warnings).toHaveLength(1); + expect(result.terminology?.warnings[0]).toMatchObject({ key: 'budget.title', locale: 'en' }); + expect(result.terminology?.valuesChecked).toBe(1); + }); + + it('scans each collection under its own base locale', () => { + vi.mocked(exportCommon.loadResourcesFromCollections).mockReturnValue([ + verified({}), + verified({ collection: 'legacy', fullKey: 'legacy.title' }), + ]); + + const result = validateResources(collections, ['es', 'fr'], { + allowTranslated: false, + terminology: { rules, baseLocaleByCollection: { main: 'en', legacy: 'en-GB' } }, + }); + + expect(result.terminology?.warnings.map((w) => `${w.collection}:${w.locale}`)).toEqual([ + 'main:en', + 'legacy:en-GB', + ]); + }); + + it('fails when the rule file could not be loaded', () => { + vi.mocked(exportCommon.loadResourcesFromCollections).mockReturnValue([verified({})]); + + const result = validateResources(collections, ['es', 'fr'], { + allowTranslated: false, + terminology: { rules: [], loadError: 'broken', baseLocaleByCollection: {} }, + }); + + expect(result.passed).toBe(false); + expect(result.terminology?.configError).toBe('broken'); + expect(result.failures).toHaveLength(0); + }); + + it('leaves the terminology result undefined when not requested', () => { + vi.mocked(exportCommon.loadResourcesFromCollections).mockReturnValue([verified({})]); + + const result = validateResources(collections, ['es', 'fr'], { allowTranslated: false }); + + expect(result.terminology).toBeUndefined(); + }); + }); }); diff --git a/libs/core/src/lib/validate/validate-resources.ts b/libs/core/src/lib/validate/validate-resources.ts index d78b631..56ec2c6 100644 --- a/libs/core/src/lib/validate/validate-resources.ts +++ b/libs/core/src/lib/validate/validate-resources.ts @@ -1,8 +1,9 @@ -import { loadResourcesFromCollections, type LoadedResource } from '../export/export-common'; import type { TranslationStatus } from '@simoncodes-ca/domain'; +import { type LoadedResource, loadResourcesFromCollections } from '../export/export-common'; +import type { ResourceValidationDetail, ResourceValidationResult, StatusCounts, ValidationOptions } from './types'; import { validateIcuValues } from './validate-icu'; import { validatePlaceholders } from './validate-placeholders'; -import type { ValidationOptions, ResourceValidationResult, ResourceValidationDetail, StatusCounts } from './types'; +import { validateTerminology } from './validate-terminology'; /** * Validates translation resources across all collections and locales. @@ -30,6 +31,10 @@ import type { ValidationOptions, ResourceValidationResult, ResourceValidationDet * argument renders as empty text rather than raising, so neither of the other * two passes can see it. * + * When `options.terminology` is provided, a fourth pass scans each collection's + * base-locale values for discouraged terms. Its findings are advisory and never + * fail validation; only a rule file that could not be loaded does. + * * The function validates ALL resources comprehensively before returning results. * This ensures teams have complete visibility into translation status across * their entire project. @@ -113,12 +118,20 @@ export function validateResources( ? validatePlaceholders(loadedResources, targetLocales, options.placeholders.baseLocale) : undefined; + // Terminology is advisory: findings suggest wording and never block. A rule + // file that failed to load does block, because then nothing was checked. + const terminology = options.terminology ? validateTerminology(loadedResources, options.terminology) : undefined; + const passed = - failures.length === 0 && (icu?.failures.length ?? 0) === 0 && (placeholders?.failures.length ?? 0) === 0; + failures.length === 0 && + (icu?.failures.length ?? 0) === 0 && + (placeholders?.failures.length ?? 0) === 0 && + terminology?.configError === undefined; return { icu, placeholders, + terminology, totalResourcesValidated, totalUniqueKeys: loadedResources.length, localesValidated: targetLocales.length, diff --git a/libs/core/src/lib/validate/validate-terminology.spec.ts b/libs/core/src/lib/validate/validate-terminology.spec.ts new file mode 100644 index 0000000..1fe19f2 --- /dev/null +++ b/libs/core/src/lib/validate/validate-terminology.spec.ts @@ -0,0 +1,113 @@ +import type { PreferredTermRule } from '@simoncodes-ca/domain'; +import { describe, expect, it } from 'vitest'; +import type { LoadedResource } from '../export/export-common'; +import { validateTerminology } from './validate-terminology'; + +const resource = (overrides: Partial = {}): LoadedResource => ({ + key: 'title', + fullKey: 'budget.title', + source: 'Capital expenditure', + translations: { fr: 'Dépenses en capital', de: 'Investitionsausgaben' }, + status: { fr: 'verified', de: 'verified' }, + collection: 'main', + ...overrides, +}); + +const expenditure: PreferredTermRule = { + discouraged: 'Expenditure', + preferred: 'Investment', + reason: 'Finance style guide', +}; +const email: PreferredTermRule = { discouraged: 'e-mail', preferred: 'email' }; + +describe('validateTerminology', () => { + it('reports a discouraged term in the base value once, with the suggestion', () => { + const result = validateTerminology([resource()], { + rules: [expenditure], + baseLocaleByCollection: { main: 'en' }, + }); + + expect(result.warnings).toEqual([ + { + key: 'budget.title', + collection: 'main', + locale: 'en', + discouraged: 'Expenditure', + preferred: 'Investment', + reason: 'Finance style guide', + message: 'consider "Investment" instead of "Expenditure"', + }, + ]); + expect(result.valuesChecked).toBe(1); + expect(result.configError).toBeUndefined(); + }); + + it('reports once per key however many target locales exist', () => { + const result = validateTerminology([resource({ translations: { fr: 'a', de: 'b', es: 'c', ja: 'd' } })], { + rules: [expenditure], + baseLocaleByCollection: { main: 'en' }, + }); + + expect(result.warnings).toHaveLength(1); + }); + + it('reports once per key however many times the term occurs', () => { + const result = validateTerminology([resource({ source: 'Expenditure, expenditure, and more EXPENDITURE' })], { + rules: [expenditure], + baseLocaleByCollection: { main: 'en' }, + }); + + expect(result.warnings).toHaveLength(1); + }); + + it('reports each matching rule separately', () => { + const result = validateTerminology([resource({ source: 'Send the expenditure report by e-mail' })], { + rules: [expenditure, email], + baseLocaleByCollection: { main: 'en' }, + }); + + expect(result.warnings.map((w) => w.discouraged)).toEqual(['Expenditure', 'e-mail']); + expect(result.warnings[1]).not.toHaveProperty('reason'); + }); + + it('never scans target-locale values', () => { + const result = validateTerminology( + [resource({ source: 'Capital spending', translations: { fr: 'Expenditure' } })], + { rules: [expenditure], baseLocaleByCollection: { main: 'en' } }, + ); + + expect(result.warnings).toEqual([]); + }); + + it("labels each finding with its collection's own base locale", () => { + const result = validateTerminology( + [resource({ collection: 'main' }), resource({ collection: 'legacy', fullKey: 'legacy.title' })], + { rules: [expenditure], baseLocaleByCollection: { main: 'en', legacy: 'en-GB' } }, + ); + + expect(result.warnings.map((w) => [w.collection, w.locale])).toEqual([ + ['main', 'en'], + ['legacy', 'en-GB'], + ]); + }); + + it('reports nothing and scans nothing when there are no rules', () => { + const result = validateTerminology([resource()], { rules: [], baseLocaleByCollection: { main: 'en' } }); + + expect(result).toEqual({ warnings: [], valuesChecked: 0 }); + }); + + it('carries a load error through as a config error and scans nothing', () => { + const result = validateTerminology([resource()], { + rules: [], + loadError: 'Preferred terminology file is not valid JSON', + baseLocaleByCollection: { main: 'en' }, + }); + + expect(result).toEqual({ + warnings: [], + configError: 'Preferred terminology file is not valid JSON', + valuesChecked: 0, + }); + }); +}); diff --git a/libs/core/src/lib/validate/validate-terminology.ts b/libs/core/src/lib/validate/validate-terminology.ts new file mode 100644 index 0000000..212dafe --- /dev/null +++ b/libs/core/src/lib/validate/validate-terminology.ts @@ -0,0 +1,72 @@ +import { findPreferredTermFindings, type PreferredTermRule } from '@simoncodes-ca/domain'; +import type { LoadedResource } from '../export/export-common'; +import type { TerminologyValidationDetail, TerminologyValidationOptions, TerminologyValidationResult } from './types'; + +/** + * Scans every base-locale value for discouraged terms from the preferred-terminology file. + * + * Unlike the other passes this one is advisory. A finding suggests better wording; + * it is never a release blocker, so it lands in `warnings` and leaves `passed` alone. + * The one failure it reports is a rule file that could not be loaded, since then + * nothing was checked at all — that surfaces as `configError`. + * + * Only the base value is scanned. It is the text authors write, and the one every + * translation is made from; target locales use their own vocabulary. `source` is the + * collection's base-locale value by construction, so a collection whose base locale + * differs from the project's is scanned in its own base locale. + * + * One detail is reported per collection, key, and rule — never per target locale or + * per occurrence — so a term used three times in one value reads as one suggestion. + * + * @param resources - Resources already loaded from the collections under validation. + * @param options - Rules, load error, and each collection's base locale. + * @returns Findings, the load error when there was one, and how many values were scanned. + */ +export function validateTerminology( + resources: readonly LoadedResource[], + options: TerminologyValidationOptions, +): TerminologyValidationResult { + if (options.loadError !== undefined) { + return { warnings: [], configError: options.loadError, valuesChecked: 0 }; + } + + if (options.rules.length === 0) { + return { warnings: [], valuesChecked: 0 }; + } + + const warnings: TerminologyValidationDetail[] = []; + let valuesChecked = 0; + + for (const resource of resources) { + if (typeof resource.source !== 'string' || resource.source.length === 0) continue; + + valuesChecked++; + + for (const finding of findPreferredTermFindings(resource.source, options.rules)) { + warnings.push(toDetail(resource, options.baseLocaleByCollection[resource.collection] ?? '', finding.rule)); + } + } + + return { warnings, valuesChecked }; +} + +/** + * The suggestion as one line, e.g. `consider "Investment" instead of "Expenditure"`. + * Shared with import and the CLI so every surface words it the same way. + */ +export function describePreferredTermRule(rule: PreferredTermRule): string { + return `consider "${rule.preferred}" instead of "${rule.discouraged}"`; +} + +/** @internal */ +function toDetail(resource: LoadedResource, locale: string, rule: PreferredTermRule): TerminologyValidationDetail { + return { + key: resource.fullKey, + collection: resource.collection, + locale, + discouraged: rule.discouraged, + preferred: rule.preferred, + ...(rule.reason ? { reason: rule.reason } : {}), + message: describePreferredTermRule(rule), + }; +} diff --git a/libs/data-transfer/src/index.ts b/libs/data-transfer/src/index.ts index d483e5a..20757ff 100644 --- a/libs/data-transfer/src/index.ts +++ b/libs/data-transfer/src/index.ts @@ -1,6 +1,7 @@ export * from './lib/lingo-tracker-config.dto'; export * from './lib/lingo-tracker-collection.dto'; export * from './lib/update-config.dto'; +export * from './lib/preferred-term-rule.dto'; export * from './lib/create-collection.dto'; export * from './lib/update-collection.dto'; export * from './lib/create-resource.dto'; diff --git a/libs/data-transfer/src/lib/lingo-tracker-config.dto.ts b/libs/data-transfer/src/lib/lingo-tracker-config.dto.ts index e34a87e..247a5ed 100644 --- a/libs/data-transfer/src/lib/lingo-tracker-config.dto.ts +++ b/libs/data-transfer/src/lib/lingo-tracker-config.dto.ts @@ -1,6 +1,7 @@ -import type { TranslationConfigDto } from './translation-config.dto'; -import type { LingoTrackerCollectionDto } from './lingo-tracker-collection.dto'; import type { BundleDefinitionDto, TokenCasingDto } from './bundle-definition.dto'; +import type { LingoTrackerCollectionDto } from './lingo-tracker-collection.dto'; +import type { PreferredTermRuleDto } from './preferred-term-rule.dto'; +import type { TranslationConfigDto } from './translation-config.dto'; export interface LingoTrackerConfigDto { exportFolder: string; @@ -19,6 +20,20 @@ export interface LingoTrackerConfigDto { protectedTerms?: string[]; /** Path of the file the global protected terms are stored in. Read-only; shown in the UI. */ protectedTermsFilePath?: string; + /** Rules from the preferred-terminology file, in file order. Omitted when there are none. */ + preferredTerminology?: PreferredTermRuleDto[]; + /** Path of the preferred-terminology file, whether or not it exists yet. Read-only; shown in the UI. */ + preferredTerminologyFilePath?: string; + /** + * Set when the preferred-terminology file exists but cannot be used (malformed JSON, wrong + * shape, invalid rules). `preferredTerminology` is then omitted and terminology checks skip. + */ + preferredTerminologyError?: string; + /** + * Set when an explicitly configured preferred-terminology file does not exist. The list reads + * as empty and checks still run; saving creates the file. + */ + preferredTerminologyWarning?: string; /** Basename of the served workspace folder. Read-only; shown as the home page title. */ projectName?: string; } diff --git a/libs/data-transfer/src/lib/preferred-term-rule.dto.ts b/libs/data-transfer/src/lib/preferred-term-rule.dto.ts new file mode 100644 index 0000000..99494f9 --- /dev/null +++ b/libs/data-transfer/src/lib/preferred-term-rule.dto.ts @@ -0,0 +1,29 @@ +/** One preferred-terminology rule: a discouraged base-locale term and the term to use instead. */ +export interface PreferredTermRuleDto { + discouraged: string; + preferred: string; + reason?: string; +} + +/** A defect in one row of a submitted rule list. Mirrors the domain `PreferredTermRuleError`. */ +export interface PreferredTermRuleErrorDto { + /** Row in the submitted `preferredTerminology` list (0-based). */ + index: number; + field: 'discouraged' | 'preferred' | 'reason' | 'rule'; + code: + | 'empty' + | 'invalid-character' + | 'self-mapping' + | 'duplicate' + | 'chain' + | 'contains-discouraged' + | 'cycle' + | 'invalid-type'; + message: string; +} + +/** Body of the 400 returned by `PUT /api/config` when the submitted rule list fails validation. */ +export interface PreferredTermRulesErrorResponseDto { + message: string; + errors: PreferredTermRuleErrorDto[]; +} diff --git a/libs/data-transfer/src/lib/update-config.dto.ts b/libs/data-transfer/src/lib/update-config.dto.ts index 7aa40f7..7c6d762 100644 --- a/libs/data-transfer/src/lib/update-config.dto.ts +++ b/libs/data-transfer/src/lib/update-config.dto.ts @@ -1,3 +1,5 @@ +import type { PreferredTermRuleDto } from './preferred-term-rule.dto'; + /** * Writable top-level configuration fields for `PUT /api/config`. * @@ -8,4 +10,9 @@ export interface UpdateConfigDto { /** Global protected terms. Written to the global protected-terms file, not into the config. */ protectedTerms?: string[]; + /** + * The full preferred-terminology rule list, replacing the file's contents. Validated + * server-side; the file is written sorted by discouraged term. + */ + preferredTerminology?: PreferredTermRuleDto[]; } diff --git a/libs/domain/src/index.ts b/libs/domain/src/index.ts index a9d4f49..ec63a66 100644 --- a/libs/domain/src/index.ts +++ b/libs/domain/src/index.ts @@ -19,3 +19,4 @@ export * from './lib/icu-locale-validation'; export * from './lib/portable-plural-categories'; export * from './lib/icu-arguments'; export * from './lib/js-identifier'; +export * from './lib/preferred-terminology'; diff --git a/libs/domain/src/lib/preferred-terminology.spec.ts b/libs/domain/src/lib/preferred-terminology.spec.ts new file mode 100644 index 0000000..6c4db23 --- /dev/null +++ b/libs/domain/src/lib/preferred-terminology.spec.ts @@ -0,0 +1,508 @@ +import { parse, type Token } from '@messageformat/parser'; +import { describe, expect, it } from 'vitest'; +import { + applyPreferredTerm, + extractVisibleTextRanges, + findPreferredTermFindings, + normalizePreferredTermRules, + type PreferredTermRule, + sortPreferredTermRules, + validatePreferredTermRules, +} from './preferred-terminology'; + +const expenditure: PreferredTermRule = { discouraged: 'Expenditure', preferred: 'Investment' }; +const customField: PreferredTermRule = { discouraged: 'Custom Field', preferred: 'Configurable Field' }; + +/** The substrings each finding's ranges cover, for readable assertions. */ +function matchedText(value: string, rules: PreferredTermRule[]): string[][] { + return findPreferredTermFindings(value, rules).map((finding) => + finding.ranges.map((range) => value.slice(range.start, range.end)), + ); +} + +/** The visible substrings of a value. */ +function visibleText(value: string): string[] { + return extractVisibleTextRanges(value).map((range) => value.slice(range.start, range.end)); +} + +describe('normalizePreferredTermRules', () => { + it('trims every field', () => { + expect( + normalizePreferredTermRules([{ discouraged: ' Expenditure ', preferred: '\tInvestment ', reason: ' Renamed ' }]), + ).toEqual([{ discouraged: 'Expenditure', preferred: 'Investment', reason: 'Renamed' }]); + }); + + it('drops an empty or whitespace-only reason', () => { + const [blank, empty] = normalizePreferredTermRules([ + { discouraged: 'a', preferred: 'b', reason: ' ' }, + { discouraged: 'c', preferred: 'd', reason: '' }, + ]); + expect(blank).toEqual({ discouraged: 'a', preferred: 'b' }); + expect('reason' in blank).toBe(false); + expect('reason' in empty).toBe(false); + }); + + it('does not mutate its input', () => { + const input = [{ discouraged: ' a ', preferred: ' b ' }]; + normalizePreferredTermRules(input); + expect(input).toEqual([{ discouraged: ' a ', preferred: ' b ' }]); + }); +}); + +describe('validatePreferredTermRules', () => { + it('accepts a valid list', () => { + expect(validatePreferredTermRules([expenditure, customField, { ...expenditure, discouraged: 'Spend' }])).toEqual( + [], + ); + }); + + it('accepts an empty list', () => { + expect(validatePreferredTermRules([])).toEqual([]); + }); + + describe('invalid-type', () => { + it.each([null, 'Expenditure', 42, ['a', 'b']])('rejects a non-object row (%j)', (row) => { + expect(validatePreferredTermRules([row])).toEqual([ + expect.objectContaining({ index: 0, field: 'rule', code: 'invalid-type' }), + ]); + }); + + it('rejects non-string terms', () => { + expect(validatePreferredTermRules([{ discouraged: 1, preferred: undefined }])).toEqual([ + expect.objectContaining({ index: 0, field: 'discouraged', code: 'invalid-type' }), + expect.objectContaining({ index: 0, field: 'preferred', code: 'invalid-type' }), + ]); + }); + + it('rejects a non-string reason but allows it to be absent', () => { + expect(validatePreferredTermRules([{ ...expenditure, reason: 5 }])).toEqual([ + expect.objectContaining({ index: 0, field: 'reason', code: 'invalid-type' }), + ]); + expect(validatePreferredTermRules([{ ...expenditure, reason: 'Renamed' }])).toEqual([]); + }); + }); + + describe('empty', () => { + it('rejects blank terms', () => { + expect(validatePreferredTermRules([{ discouraged: ' ', preferred: '' }])).toEqual([ + expect.objectContaining({ index: 0, field: 'discouraged', code: 'empty' }), + expect.objectContaining({ index: 0, field: 'preferred', code: 'empty' }), + ]); + }); + }); + + describe('invalid-character', () => { + it.each(['{', '}', '<', '>'])('rejects %s in either term', (char) => { + expect(validatePreferredTermRules([{ discouraged: `Field${char}`, preferred: `Input ${char}x` }])).toEqual([ + { + index: 0, + field: 'discouraged', + code: 'invalid-character', + message: 'Discouraged term cannot contain "{", "}", "<" or ">".', + }, + { + index: 0, + field: 'preferred', + code: 'invalid-character', + message: 'Preferred term cannot contain "{", "}", "<" or ">".', + }, + ]); + }); + + it('reports only the offending field', () => { + expect(validatePreferredTermRules([{ discouraged: 'Field', preferred: '{count} fields' }])).toEqual([ + expect.objectContaining({ index: 0, field: 'preferred', code: 'invalid-character' }), + ]); + expect(validatePreferredTermRules([{ discouraged: 'Field', preferred: 'Input' }])).toEqual([ + expect.objectContaining({ index: 0, field: 'discouraged', code: 'invalid-character' }), + ]); + }); + + it('excludes the row from cross-row checks', () => { + expect( + validatePreferredTermRules([ + { discouraged: 'Field', preferred: 'Input' }, + { discouraged: 'Field', preferred: 'Input {x}' }, + ]), + ).toEqual([expect.objectContaining({ index: 1, field: 'preferred', code: 'invalid-character' })]); + }); + }); + + describe('self-mapping', () => { + it('rejects a rule mapping a term to itself, case-insensitively', () => { + expect(validatePreferredTermRules([{ discouraged: 'Email', preferred: 'email' }])).toEqual([ + expect.objectContaining({ index: 0, field: 'preferred', code: 'self-mapping' }), + ]); + }); + + it('compares trimmed values', () => { + expect(validatePreferredTermRules([{ discouraged: 'Email ', preferred: ' EMAIL' }])).toEqual([ + expect.objectContaining({ code: 'self-mapping' }), + ]); + }); + }); + + describe('duplicate', () => { + it('reports the later row when a discouraged term repeats, case-insensitively', () => { + expect( + validatePreferredTermRules([expenditure, customField, { discouraged: 'EXPENDITURE', preferred: 'Spend' }]), + ).toEqual([expect.objectContaining({ index: 2, field: 'discouraged', code: 'duplicate' })]); + }); + }); + + describe('chain', () => { + it('rejects a preferred term that is another rule’s discouraged term', () => { + expect( + validatePreferredTermRules([ + { discouraged: 'Expenditure', preferred: 'spend' }, + { discouraged: 'Spend', preferred: 'Investment' }, + ]), + ).toEqual([expect.objectContaining({ index: 0, field: 'preferred', code: 'chain' })]); + }); + }); + + describe('cycle', () => { + it('reports a cycle rather than a chain when the links lead back', () => { + expect( + validatePreferredTermRules([ + { discouraged: 'Expenditure', preferred: 'Investment' }, + { discouraged: 'investment', preferred: 'expenditure' }, + ]), + ).toEqual([ + expect.objectContaining({ index: 0, field: 'preferred', code: 'cycle' }), + expect.objectContaining({ index: 1, field: 'preferred', code: 'cycle' }), + ]); + }); + + it('detects longer cycles', () => { + const errors = validatePreferredTermRules([ + { discouraged: 'a', preferred: 'b' }, + { discouraged: 'b', preferred: 'c' }, + { discouraged: 'c', preferred: 'a' }, + ]); + expect(errors.map((error) => error.code)).toEqual(['cycle', 'cycle', 'cycle']); + }); + }); + + describe('contains-discouraged', () => { + it('rejects a preferred term containing its own discouraged term as a word', () => { + expect(validatePreferredTermRules([{ discouraged: 'Field', preferred: 'Configurable field' }])).toEqual([ + expect.objectContaining({ index: 0, field: 'preferred', code: 'contains-discouraged' }), + ]); + }); + + it('rejects a preferred term containing another rule’s discouraged term', () => { + expect( + validatePreferredTermRules([ + { discouraged: 'Expenditure', preferred: 'Capital spend' }, + { discouraged: 'Spend', preferred: 'Outlay' }, + ]), + ).toEqual([expect.objectContaining({ index: 0, field: 'preferred', code: 'contains-discouraged' })]); + }); + + it('ignores containment that is not a whole word', () => { + expect(validatePreferredTermRules([{ discouraged: 'Field', preferred: 'Fieldset' }])).toEqual([]); + expect(validatePreferredTermRules([{ discouraged: 'Field', preferred: 'field_name' }])).toEqual([]); + }); + + it('treats a hyphen as a word boundary', () => { + expect(validatePreferredTermRules([{ discouraged: 'Field', preferred: 'Multi-field' }])).toEqual([ + expect.objectContaining({ code: 'contains-discouraged' }), + ]); + }); + }); + + it('reports errors in row order with human-readable messages', () => { + const errors = validatePreferredTermRules([expenditure, { discouraged: '', preferred: 'x' }, 'bad']); + expect(errors.map((error) => error.index)).toEqual([1, 2]); + for (const error of errors) { + expect(error.message.length).toBeGreaterThan(0); + } + }); +}); + +describe('sortPreferredTermRules', () => { + it('sorts by discouraged term, case-insensitively, without mutating the input', () => { + const input = [ + { discouraged: 'custom field', preferred: 'x' }, + { discouraged: 'Expenditure', preferred: 'y' }, + { discouraged: 'Account', preferred: 'z' }, + ]; + expect(sortPreferredTermRules(input).map((rule) => rule.discouraged)).toEqual([ + 'Account', + 'custom field', + 'Expenditure', + ]); + expect(input[0].discouraged).toBe('custom field'); + }); +}); + +describe('extractVisibleTextRanges', () => { + it('returns the whole value for plain text', () => { + expect(extractVisibleTextRanges('Plain text')).toEqual([{ start: 0, end: 10 }]); + }); + + it('returns nothing for an empty value', () => { + expect(extractVisibleTextRanges('')).toEqual([]); + }); + + it('excludes ICU argument names and formats', () => { + expect(visibleText('Paid {amount, number, currency} on {date}.')).toEqual(['Paid ', ' on ', '.']); + }); + + it('includes plural branch text but not selectors, the argument or #', () => { + expect(visibleText('{count, plural, one {# item} other {# items}}')).toEqual([' item', ' items']); + }); + + it('excludes Transloco placeholders with offsets into the raw value', () => { + const value = 'Hello {{ name }}, welcome'; + expect(extractVisibleTextRanges(value)).toEqual([ + { start: 0, end: 6 }, + { start: 16, end: 25 }, + ]); + }); + + it('masks whole tags, attributes included, keeping the text between them', () => { + expect(visibleText('Read the guide')).toEqual([ + 'Read ', + 'the guide', + ]); + }); + + it('masks a tag whose quoted attribute value contains ">"', () => { + expect(visibleText('Text')).toEqual(['Text']); + expect(visibleText("Text")).toEqual(['Text']); + }); + + it('masks tags whose name starts with a non-ASCII letter, "_" or ":"', () => { + expect(visibleText('<étiquette title="Expenditure">Text')).toEqual(['Text']); + expect(visibleText('<_x a="Expenditure">A<:y b="Expenditure">B')).toEqual(['A', 'B']); + }); + + it('keeps a "<" that does not open a tag visible', () => { + expect(visibleText('a < b')).toEqual(['a < b']); + expect(visibleText('5 <3 and 2 > 1')).toEqual(['5 <3 and 2 > 1']); + }); + + it('does not let an unclosed tag or quote swallow the rest of the value', () => { + expect(visibleText(' { + expect(visibleText("It's Expenditure, isn't it")).toEqual(["It's ", 'Expenditure', ", isn't it"]); + }); + + it('falls back to masking braces and tags when ICU parsing fails', () => { + expect(visibleText('Broken {count, plural, one {x} and more text')).toEqual(['Broken ']); + expect(visibleText('Oops {first name} then text')).toEqual(['Oops ', ' then ', 'text']); + }); +}); + +describe('findPreferredTermFindings', () => { + describe('word boundaries (issue examples)', () => { + it.each([ + ['capital expenditure', true], + ['Expenditure', true], + ['EXPENDITURE report', true], + ['expenditure-report', true], + ["the expenditure's owner", true], + ['(expenditure)', true], + ['Expenditures', false], + ['ExpenditureType', false], + ['expenditure_id', false], + ['preexpenditure', false], + ['expenditure2', false], + ])('%j → %s', (value, expected) => { + expect(findPreferredTermFindings(value, [expenditure]).length > 0).toBe(expected); + }); + }); + + it('matches phrases with internal spaces', () => { + expect(matchedText('Add a custom field here', [customField])).toEqual([['custom field']]); + expect(matchedText('Add a customfield here', [customField])).toEqual([]); + }); + + it('matches non-ASCII letters with the same boundary rules', () => { + const rule = { discouraged: 'Café', preferred: 'Coffee shop' }; + expect(matchedText('Le café ouvert', [rule])).toEqual([['café']]); + expect(matchedText('Cafés', [rule])).toEqual([]); + }); + + it('escapes regex metacharacters in the term', () => { + const rule = { discouraged: 'C++', preferred: 'C plus plus' }; + expect(matchedText('Learn C++ today', [rule])).toEqual([['C++']]); + expect(matchedText('Learn C today', [rule])).toEqual([]); + }); + + it('ignores an ICU argument named after the term', () => { + expect(findPreferredTermFindings('Total: {expenditure}', [expenditure])).toEqual([]); + expect(findPreferredTermFindings('Total: {expenditure, number}', [expenditure])).toEqual([]); + }); + + it('ignores a select selector named after the term', () => { + const value = '{kind, select, expenditure {Spend} other {Other}}'; + expect(findPreferredTermFindings(value, [expenditure])).toEqual([]); + }); + + it('matches plural branch text', () => { + const value = '{count, plural, one {# expenditure} other {# expenditures}}'; + const [finding] = findPreferredTermFindings(value, [expenditure]); + expect(finding).toBeDefined(); + expect(finding?.ranges).toEqual([{ start: 23, end: 34 }]); + expect(value.slice(23, 34)).toBe('expenditure'); + }); + + it('never treats # as part of a match', () => { + const rule = { discouraged: '#', preferred: 'number' }; + expect(findPreferredTermFindings('{n, plural, other {# items}}', [rule])).toEqual([]); + }); + + it('ignores Transloco placeholders named after the term', () => { + expect(findPreferredTermFindings('Total: {{ expenditure }}', [expenditure])).toEqual([]); + expect(matchedText('{{ expenditure }} Expenditure', [expenditure])).toEqual([['Expenditure']]); + }); + + it('ignores tags and attributes but matches text between tags', () => { + const value = 'Expenditure'; + expect(findPreferredTermFindings(value, [expenditure])).toEqual([ + { rule: expenditure, ranges: [{ start: 33, end: 44 }] }, + ]); + }); + + it('still scans visible text when ICU parsing fails', () => { + const value = 'Expenditure {count, plural, one {expenditure} Expenditure'; + expect(matchedText(value, [expenditure])).toEqual([['Expenditure']]); + }); + + it('returns one finding with every range for multiple occurrences', () => { + const value = 'Expenditure and more expenditure'; + expect(findPreferredTermFindings(value, [expenditure])).toEqual([ + { + rule: expenditure, + ranges: [ + { start: 0, end: 11 }, + { start: 21, end: 32 }, + ], + }, + ]); + }); + + it('returns one finding per matched rule, in rule order', () => { + const value = 'Custom field for expenditure'; + const findings = findPreferredTermFindings(value, [ + expenditure, + { discouraged: 'Spend', preferred: 'x' }, + customField, + ]); + expect(findings.map((finding) => finding.rule)).toEqual([expenditure, customField]); + }); + + it('requires a match to lie entirely within visible text', () => { + const rule = { discouraged: 'total amount', preferred: 'sum' }; + expect(findPreferredTermFindings('Total amount', [rule])).toEqual([]); + }); + + it('returns nothing without rules, for empty values, or for blank terms', () => { + expect(findPreferredTermFindings('Expenditure', [])).toEqual([]); + expect(findPreferredTermFindings('', [expenditure])).toEqual([]); + expect(findPreferredTermFindings('Expenditure', [{ discouraged: ' ', preferred: 'x' }])).toEqual([]); + }); +}); + +describe('applyPreferredTerm', () => { + it('replaces every occurrence with the preferred term verbatim', () => { + expect(applyPreferredTerm('EXPENDITURE and expenditure', expenditure)).toBe('Investment and Investment'); + }); + + it('leaves arguments, placeholders and tags untouched', () => { + const value = 'Expenditure for {expenditure} and {{ expenditure }}'; + expect(applyPreferredTerm(value, expenditure)).toBe( + 'Investment for {expenditure} and {{ expenditure }}', + ); + }); + + it('replaces inside plural branches without touching the structure', () => { + expect(applyPreferredTerm('{expenditure, plural, one {# expenditure} other {# items}}', expenditure)).toBe( + '{expenditure, plural, one {# Investment} other {# items}}', + ); + }); + + it('replaces phrases of a different length', () => { + expect(applyPreferredTerm('A custom field, another Custom Field.', customField)).toBe( + 'A Configurable Field, another Configurable Field.', + ); + }); + + it('returns the value unchanged when nothing matches', () => { + expect(applyPreferredTerm('Expenditures', expenditure)).toBe('Expenditures'); + }); + + describe('a preferred term containing "#"', () => { + const cSharp: PreferredTermRule = { discouraged: 'Old', preferred: 'C#' }; + + /** The literal text of the first branch of the top-level select/plural, as the ICU parser reads it. */ + const firstBranch = (message: string): Token[] => { + const [root] = parse(message); + if (root?.type !== 'plural' && root?.type !== 'select' && root?.type !== 'selectordinal') { + throw new Error(`Not a select or plural: ${message}`); + } + return root.cases[0].tokens; + }; + + it('quotes "#" inside a plural branch so it stays literal', () => { + const result = applyPreferredTerm('{count, plural, other {Old item}}', cSharp); + + expect(result).toBe("{count, plural, other {C'#' item}}"); + expect(firstBranch(result)).toEqual([expect.objectContaining({ type: 'content', value: 'C# item' })]); + }); + + it('quotes "#" inside a selectordinal branch', () => { + const result = applyPreferredTerm('{n, selectordinal, other {Old}}', cSharp); + + expect(firstBranch(result)).toEqual([expect.objectContaining({ type: 'content', value: 'C#' })]); + }); + + it('quotes "#" inside a select nested in a plural branch, where "#" is still the count', () => { + const result = applyPreferredTerm('{count, plural, other {{g, select, a {Old x} other {y}}}}', cSharp); + + expect(result).toBe("{count, plural, other {{g, select, a {C'#' x} other {y}}}}"); + const [select] = firstBranch(result); + expect(select?.type === 'select' ? select.cases[0].tokens : []).toEqual([ + expect.objectContaining({ type: 'content', value: 'C# x' }), + ]); + }); + + it('inserts "#" verbatim outside plural branches', () => { + expect(applyPreferredTerm('Old item', cSharp)).toBe('C# item'); + expect(applyPreferredTerm('{g, select, a {Old} other {x}}', cSharp)).toBe('{g, select, a {C#} other {x}}'); + expect(applyPreferredTerm('{count, plural, other {# x}} Old', cSharp)).toBe('{count, plural, other {# x}} C#'); + }); + + it('inserts verbatim when the value does not parse as ICU', () => { + expect(applyPreferredTerm('Old {count, plural, other {x}', cSharp)).toBe('C# {count, plural, other {x}'); + }); + + it('keeps apostrophes around and inside the term literal', () => { + const quotedWord = applyPreferredTerm("{count, plural, other {'Old' item}}", cSharp); + expect(firstBranch(quotedWord)).toEqual([expect.objectContaining({ type: 'content', value: "'C#' item" })]); + + const apostropheTerm = applyPreferredTerm('{count, plural, other {Old item}}', { + discouraged: 'Old', + preferred: "It's '#1'", + }); + expect(firstBranch(apostropheTerm)).toEqual([ + expect.objectContaining({ type: 'content', value: "It's '#1' item" }), + ]); + }); + + it('keeps existing quoting and the count placeholder elsewhere in the branch', () => { + const result = applyPreferredTerm("{count, plural, other {# Old, it''s '{x}'}}", cSharp); + + expect(firstBranch(result)).toEqual([ + expect.objectContaining({ type: 'octothorpe' }), + expect.objectContaining({ type: 'content', value: " C#, it's {x}" }), + ]); + }); + }); +}); diff --git a/libs/domain/src/lib/preferred-terminology.ts b/libs/domain/src/lib/preferred-terminology.ts new file mode 100644 index 0000000..658b9ae --- /dev/null +++ b/libs/domain/src/lib/preferred-terminology.ts @@ -0,0 +1,650 @@ +import { parse, type Token } from '@messageformat/parser'; +import { escapeRegExp } from './escape-regexp'; +import { maskTranslocoPlaceholders } from './transloco-brace-scan'; + +/** + * Preferred terminology: a curated list of discouraged source-language terms, each + * mapped to the term the product now uses instead. + * + * A finding is advice, never an error. An older term can still be right in a quotation + * or a historical note, so everything here reports and suggests; nothing here rejects a + * value. The only hard failures are in the rule list itself. + * + * Matching is case-insensitive and whole-word. Letters, digits and `_` are word + * characters; everything else, including `-` and `'`, is a boundary. So `Expenditure` + * matches `capital expenditure` and `expenditure-report` but not `Expenditures`, + * `ExpenditureType` or `expenditure_id`. + * + * Only text a reader sees is scanned: ICU literal content, outside argument names, + * formats, selectors, `#`, Transloco placeholders and HTML/XML tags. + * + * @module preferred-terminology + */ + +/** One mapping from a discouraged term to its preferred replacement. */ +export interface PreferredTermRule { + discouraged: string; + preferred: string; + reason?: string; +} + +/** A defect in one row of a submitted rule list. */ +export interface PreferredTermRuleError { + /** Row in the submitted list. */ + index: number; + field: 'discouraged' | 'preferred' | 'reason' | 'rule'; + code: + | 'empty' + | 'invalid-character' + | 'self-mapping' + | 'duplicate' + | 'chain' + | 'contains-discouraged' + | 'cycle' + | 'invalid-type'; + message: string; +} + +/** A half-open span `[start, end)` of UTF-16 code units in a raw value. */ +export interface PreferredTermRange { + start: number; + end: number; +} + +/** Every occurrence of one rule's discouraged term in a value. */ +export interface PreferredTermFinding { + rule: PreferredTermRule; + /** Spans in the raw value, used by replacement and highlighting. */ + ranges: ReadonlyArray; +} + +/** + * Trims every field and drops a `reason` that is empty after trimming. + * + * Does not dedupe or validate; run `validatePreferredTermRules` for that. + * + * @example + * ```typescript + * normalizePreferredTermRules([{ discouraged: ' Expenditure ', preferred: 'Investment', reason: ' ' }]); + * // → [{ discouraged: 'Expenditure', preferred: 'Investment' }] + * ``` + */ +export function normalizePreferredTermRules(rules: readonly PreferredTermRule[]): PreferredTermRule[] { + return rules.map((rule) => { + const normalized: PreferredTermRule = { + discouraged: rule.discouraged.trim(), + preferred: rule.preferred.trim(), + }; + const reason = rule.reason?.trim(); + if (reason) { + normalized.reason = reason; + } + return normalized; + }); +} + +/** Characters a term may not contain: ICU syntax and tag delimiters. */ +const INVALID_TERM_CHARACTERS = /[{}<>]/; + +/** A row that passed the shape checks, reduced to what the cross-row checks compare. */ +interface CheckedRow { + index: number; + discouraged: string; + preferred: string; +} + +/** + * Validates a submitted rule list, returning one entry per defect (empty when valid). + * + * Accepts `unknown` rows so the API and the file reader share one shape check: a row + * that is not an object, or a field that is not a string, is reported as `invalid-type` + * rather than thrown. Terms are compared after trimming and case-insensitively, so + * `Email → email` is a self-mapping. + * + * Codes, per row: + * + * - `invalid-type` — the row is not an object, or a field is not a string. + * - `empty` — `discouraged` or `preferred` is blank. + * - `invalid-character` — `discouraged` or `preferred` contains `{`, `}`, `<` or `>`. + * ICU syntax and tags are masked from visible text, so such a discouraged term can + * never match, and such a preferred term would corrupt the message when applied. + * - `duplicate` — `discouraged` repeats an earlier row's (reported on the later row). + * - `self-mapping` — `preferred` equals the row's own `discouraged`. + * - `cycle` — following preferred → discouraged from this row leads back to it. + * - `chain` — `preferred` equals another row's `discouraged`. + * - `contains-discouraged` — `preferred` contains a discouraged term, its own included, + * as a whole word, so applying the suggestion would itself be flagged. + * + * At most one error is reported per field; for `preferred` the order above is the + * priority. + */ +export function validatePreferredTermRules(rules: readonly unknown[]): PreferredTermRuleError[] { + const errors: PreferredTermRuleError[] = []; + const rows: CheckedRow[] = []; + + rules.forEach((rule, index) => { + if (typeof rule !== 'object' || rule === null || Array.isArray(rule)) { + errors.push({ index, field: 'rule', code: 'invalid-type', message: 'Rule must be an object.' }); + return; + } + + const { discouraged, preferred, reason } = rule as Record; + let shapeOk = true; + + for (const [field, fieldValue] of [ + ['discouraged', discouraged], + ['preferred', preferred], + ] as const) { + if (typeof fieldValue !== 'string') { + errors.push({ index, field, code: 'invalid-type', message: `${label(field)} must be a string.` }); + shapeOk = false; + } else if (fieldValue.trim().length === 0) { + errors.push({ index, field, code: 'empty', message: `${label(field)} is required.` }); + shapeOk = false; + } else if (INVALID_TERM_CHARACTERS.test(fieldValue)) { + errors.push({ + index, + field, + code: 'invalid-character', + message: `${label(field)} cannot contain "{", "}", "<" or ">".`, + }); + shapeOk = false; + } + } + + if (reason !== undefined && typeof reason !== 'string') { + errors.push({ index, field: 'reason', code: 'invalid-type', message: 'Reason must be a string.' }); + } + + if (shapeOk && typeof discouraged === 'string' && typeof preferred === 'string') { + rows.push({ index, discouraged: discouraged.trim(), preferred: preferred.trim() }); + } + }); + + // First row per case-folded discouraged term; later rows are duplicates. + const byDiscouraged = new Map(); + for (const row of rows) { + const key = fold(row.discouraged); + const first = byDiscouraged.get(key); + if (first) { + errors.push({ + index: row.index, + field: 'discouraged', + code: 'duplicate', + message: `"${row.discouraged}" is already listed (row ${first.index + 1}).`, + }); + } else { + byDiscouraged.set(key, row); + } + } + + const discouragedRegexes = [...byDiscouraged.values()].map((row) => ({ + term: row.discouraged, + regex: buildPreferredTermRegex(row.discouraged), + })); + + for (const row of rows) { + const error = checkPreferred(row, byDiscouraged, discouragedRegexes); + if (error) { + errors.push(error); + } + } + + return errors.sort((a, b) => a.index - b.index); +} + +/** Runs the `preferred` checks for one row, returning the highest-priority failure. */ +function checkPreferred( + row: CheckedRow, + byDiscouraged: ReadonlyMap, + discouragedRegexes: ReadonlyArray<{ term: string; regex: RegExp }>, +): PreferredTermRuleError | undefined { + const { index, discouraged, preferred } = row; + + if (fold(preferred) === fold(discouraged)) { + return { + index, + field: 'preferred', + code: 'self-mapping', + message: `Preferred term must differ from the discouraged term "${discouraged}".`, + }; + } + + const target = byDiscouraged.get(fold(preferred)); + if (target) { + if (leadsBackTo(row, byDiscouraged)) { + return { + index, + field: 'preferred', + code: 'cycle', + message: `"${discouraged}" → "${preferred}" forms a cycle that leads back to "${discouraged}".`, + }; + } + return { + index, + field: 'preferred', + code: 'chain', + message: `"${preferred}" is itself discouraged (row ${target.index + 1}); map "${discouraged}" to its final preferred term instead.`, + }; + } + + for (const { term, regex } of discouragedRegexes) { + regex.lastIndex = 0; + if (regex.test(preferred)) { + return { + index, + field: 'preferred', + code: 'contains-discouraged', + message: `"${preferred}" contains the discouraged term "${term}".`, + }; + } + } + + return undefined; +} + +/** Whether following preferred → discouraged links from `start` returns to `start`. */ +function leadsBackTo(start: CheckedRow, byDiscouraged: ReadonlyMap): boolean { + const visited = new Set(); + let current = byDiscouraged.get(fold(start.preferred)); + while (current && !visited.has(current)) { + if (fold(current.discouraged) === fold(start.discouraged)) { + return true; + } + visited.add(current); + current = byDiscouraged.get(fold(current.preferred)); + } + return false; +} + +function label(field: 'discouraged' | 'preferred'): string { + return field === 'discouraged' ? 'Discouraged term' : 'Preferred term'; +} + +function fold(term: string): string { + return term.toLowerCase(); +} + +/** + * Returns a copy sorted by `discouraged`, case-insensitively, the order the rule file is + * written in. Ties keep their input order. + */ +export function sortPreferredTermRules(rules: readonly PreferredTermRule[]): PreferredTermRule[] { + return [...rules].sort((a, b) => a.discouraged.localeCompare(b.discouraged, 'en', { sensitivity: 'accent' })); +} + +/** + * Builds the whole-word, case-insensitive regex for a term. Letters, digits and `_` are + * word characters; anything else is a boundary. + */ +function buildPreferredTermRegex(term: string): RegExp { + return new RegExp(`(?` inside one does not end the tag. No part + * of a tag, quoted values included, may contain `<`, so an unclosed tag or quote never + * swallows the markup after it. + */ +const TAG_PATTERN = /|<\/?[\p{L}_:][^<>"']*(?:(?:"[^"<]*"|'[^'<]*')[^<>"']*)*>/gu; + +/** + * Returns the spans of `value` a reader sees, as offsets into the raw value, sorted and + * non-overlapping. + * + * Visible means ICU literal content. Argument names, formats and styles, select and + * plural selectors, and `#` are excluded; plural/select branch text is included. + * Transloco `{{ name }}` placeholders are excluded. Whole HTML/XML tags, attributes + * included, are excluded, while the text between tags is kept. + * + * When the value does not parse as ICU, every `{…}` span (nested braces included; an + * unclosed `{` runs to the end) and every tag is masked and the rest is visible. + * + * Approximation: ranges cover the raw source text of each literal, so ICU quoting is not + * unescaped. `It''s` stays two apostrophes wide, and a quoted literal such as `'{x}'` + * is visible including its quotes and braces. A term containing an apostrophe + * therefore will not match a doubled `''` in the source. + * + * @example + * ```typescript + * extractVisibleTextRanges('Hi {name}, welcome'); + * // → [{ start: 0, end: 3 }, { start: 9, end: 11 }, { start: 14, end: 21 }] + * ``` + */ +export function extractVisibleTextRanges(value: string): PreferredTermRange[] { + let candidates: PreferredTermRange[]; + try { + candidates = []; + collectContentRanges(parse(maskTranslocoPlaceholders(value)), candidates); + } catch { + candidates = braceFreeRanges(value); + } + + return subtractRanges(candidates, tagRanges(value)); +} + +/** Adds the raw span of every literal content token, descending into branch bodies. */ +function collectContentRanges(tokens: readonly Token[], out: PreferredTermRange[]): void { + for (const token of tokens) { + switch (token.type) { + case 'content': + if (token.ctx) { + out.push({ start: token.ctx.offset, end: token.ctx.offset + token.ctx.text.length }); + } + break; + + // Argument names, formats and their style params, and `#` are never visible text. + case 'argument': + case 'function': + case 'octothorpe': + break; + + default: + for (const branch of token.cases) { + collectContentRanges(branch.tokens, out); + } + break; + } + } +} + +/** Fallback for unparseable values: everything outside `{…}` spans. */ +function braceFreeRanges(value: string): PreferredTermRange[] { + const ranges: PreferredTermRange[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i < value.length; i++) { + const char = value[i]; + if (char === '{') { + if (depth === 0 && i > start) { + ranges.push({ start, end: i }); + } + depth++; + } else if (char === '}') { + if (depth === 0) { + // A stray `}` is masked on its own. + if (i > start) { + ranges.push({ start, end: i }); + } + start = i + 1; + } else { + depth--; + if (depth === 0) { + start = i + 1; + } + } + } + } + if (depth === 0 && start < value.length) { + ranges.push({ start, end: value.length }); + } + return ranges; +} + +function tagRanges(value: string): PreferredTermRange[] { + return [...value.matchAll(TAG_PATTERN)].map((match) => ({ + start: match.index, + end: match.index + match[0].length, + })); +} + +/** Removes every masked span from the candidates, merging adjacent results. */ +function subtractRanges( + candidates: readonly PreferredTermRange[], + masks: readonly PreferredTermRange[], +): PreferredTermRange[] { + const result: PreferredTermRange[] = []; + const sorted = [...candidates].sort((a, b) => a.start - b.start); + + for (const candidate of sorted) { + let pieces: PreferredTermRange[] = [candidate]; + for (const mask of masks) { + pieces = pieces.flatMap((piece) => { + if (mask.end <= piece.start || mask.start >= piece.end) { + return [piece]; + } + const kept: PreferredTermRange[] = []; + if (mask.start > piece.start) kept.push({ start: piece.start, end: mask.start }); + if (mask.end < piece.end) kept.push({ start: mask.end, end: piece.end }); + return kept; + }); + } + + for (const piece of pieces) { + const last = result[result.length - 1]; + if (last && last.end >= piece.start) { + last.end = Math.max(last.end, piece.end); + } else if (piece.end > piece.start) { + result.push({ ...piece }); + } + } + } + + return result; +} + +/** + * Finds the rules whose discouraged term appears in the visible text of `value`. + * + * Returns at most one finding per rule, in rule order, each carrying every matching + * range. A match counts only when it lies entirely within one visible range. Rules with + * a blank discouraged term are ignored. + * + * @example + * ```typescript + * findPreferredTermFindings('Capital expenditure', [{ discouraged: 'Expenditure', preferred: 'Investment' }]); + * // → [{ rule, ranges: [{ start: 8, end: 19 }] }] + * ``` + */ +export function findPreferredTermFindings(value: string, rules: readonly PreferredTermRule[]): PreferredTermFinding[] { + const findings: PreferredTermFinding[] = []; + if (rules.length === 0 || value.length === 0) { + return findings; + } + + const visible = extractVisibleTextRanges(value); + if (visible.length === 0) { + return findings; + } + + for (const rule of rules) { + const term = rule.discouraged.trim(); + if (term.length === 0) { + continue; + } + + const ranges: PreferredTermRange[] = []; + for (const match of value.matchAll(buildPreferredTermRegex(term))) { + const start = match.index; + const end = start + match[0].length; + if (visible.some((range) => range.start <= start && end <= range.end)) { + ranges.push({ start, end }); + } + } + + if (ranges.length > 0) { + findings.push({ rule, ranges }); + } + } + + return findings; +} + +/** + * Replaces every visible occurrence of the rule's discouraged term with `rule.preferred`. + * Arguments, placeholders and tags are never touched. Returns `value` unchanged when + * nothing matches. + * + * The preferred term is inserted verbatim, except inside plural/selectordinal branch text + * (a `select` nested in one included), where a bare `#` would become the count. There, + * when the preferred term contains `#`, the branch's literal text is re-quoted with ICU + * apostrophe quoting so the term reads back exactly as written. Values that do not parse + * as ICU are always edited verbatim. + * + * @example + * ```typescript + * applyPreferredTerm('Expenditure for {expenditure}', { discouraged: 'Expenditure', preferred: 'Investment' }); + * // → 'Investment for {expenditure}' + * + * applyPreferredTerm('{n, plural, other {Old item}}', { discouraged: 'Old', preferred: 'C#' }); + * // → "{n, plural, other {C'#' item}}" + * ``` + */ +export function applyPreferredTerm(value: string, rule: PreferredTermRule): string { + const [finding] = findPreferredTermFindings(value, [rule]); + if (!finding) { + return value; + } + + const pluralContent = rule.preferred.includes('#') ? pluralContentRanges(value) : []; + const edits: Array = []; + for (const content of pluralContent) { + const inside = finding.ranges.filter((range) => content.start <= range.start && range.end <= content.end); + if (inside.length > 0) { + edits.push({ ...content, text: requoteWithReplacements(value, content, inside, rule.preferred) }); + } + } + for (const range of finding.ranges) { + if (!edits.some((edit) => edit.start <= range.start && range.end <= edit.end)) { + edits.push({ ...range, text: rule.preferred }); + } + } + + let result = value; + for (const edit of edits.sort((a, b) => b.start - a.start)) { + result = result.slice(0, edit.start) + edit.text + result.slice(edit.end); + } + return result; +} + +/** + * Raw spans of the literal content tokens where `#` is the count placeholder: plural and + * selectordinal branch text, and the text of any select nested in one. Mirrors + * `@messageformat/parser` in its default, non-strict mode. Empty when `value` does not + * parse as ICU. + */ +function pluralContentRanges(value: string): PreferredTermRange[] { + const ranges: PreferredTermRange[] = []; + const walk = (tokens: readonly Token[], inPlural: boolean): void => { + for (const token of tokens) { + if (token.type === 'content') { + if (inPlural && token.ctx) { + ranges.push({ start: token.ctx.offset, end: token.ctx.offset + token.ctx.text.length }); + } + } else if (token.type === 'plural' || token.type === 'selectordinal' || token.type === 'select') { + const branchInPlural = inPlural || token.type !== 'select'; + for (const branch of token.cases) { + walk(branch.tokens, branchInPlural); + } + } + } + }; + + try { + walk(parse(maskTranslocoPlaceholders(value)), false); + } catch { + return []; + } + return ranges; +} + +/** + * Rebuilds one plural-context content token: decodes its raw text to the literal a reader + * sees, swaps each matched span for `preferred`, and re-encodes the result. + */ +function requoteWithReplacements( + value: string, + content: PreferredTermRange, + matches: readonly PreferredTermRange[], + preferred: string, +): string { + const { literal, rawToLiteral } = decodeIcuLiteral(value.slice(content.start, content.end)); + + let replaced = literal; + for (let i = matches.length - 1; i >= 0; i--) { + const start = rawToLiteral[matches[i].start - content.start]; + const end = rawToLiteral[matches[i].end - content.start]; + replaced = replaced.slice(0, start) + preferred + replaced.slice(end); + } + return encodePluralIcuLiteral(replaced); +} + +/** `'{…}'`, `'}…'` or `'#…'` up to a closing apostrophe not followed by another; as in `@messageformat/parser`. */ +const ICU_QUOTED_PATTERN = /'[{}#](?:[^']|'')*'(?!')/uy; + +/** + * Decodes the raw text of one content token (no bare `{`, `}` or `#`) to its literal, + * with the literal offset at each raw offset. `''` reads as `'`; a quoted section reads + * as its contents. + */ +function decodeIcuLiteral(raw: string): { literal: string; rawToLiteral: number[] } { + let literal = ''; + const rawToLiteral: number[] = []; + let i = 0; + while (i < raw.length) { + if (raw.startsWith("''", i)) { + rawToLiteral.push(literal.length, literal.length + 1); + literal += "'"; + i += 2; + continue; + } + + ICU_QUOTED_PATTERN.lastIndex = i; + const quoted = ICU_QUOTED_PATTERN.exec(raw); + if (quoted) { + const end = i + quoted[0].length - 1; + rawToLiteral.push(literal.length); + for (i++; i < end; i++) { + rawToLiteral.push(literal.length); + if (raw.startsWith("''", i)) { + rawToLiteral.push(literal.length + 1); + i++; + } + literal += raw[i]; + } + rawToLiteral.push(literal.length); + i++; + continue; + } + + rawToLiteral.push(literal.length); + literal += raw[i]; + i++; + } + rawToLiteral.push(literal.length); + return { literal, rawToLiteral }; +} + +/** + * Encodes a literal as plural-context ICU text that parses back to exactly the literal. + * Each run of `{`, `}` and `#` is quoted, taking in any apostrophes that follow it so the + * closing quote is never followed by another. An apostrophe is doubled wherever a single + * one would start a quote, pair with its neighbour, or sit last before a following token. + */ +function encodePluralIcuLiteral(literal: string): string { + const isSyntax = (char: string | undefined): boolean => char === '{' || char === '}' || char === '#'; + + let out = ''; + let i = 0; + while (i < literal.length) { + const char = literal[i]; + if (isSyntax(char)) { + let quoted = ''; + while (i < literal.length && (isSyntax(literal[i]) || literal[i] === "'")) { + quoted += literal[i] === "'" ? "''" : literal[i]; + i++; + } + out += `'${quoted}'`; + } else if (char === "'") { + const next = literal[i + 1]; + out += next === undefined || next === "'" || isSyntax(next) ? "''" : "'"; + i++; + } else { + out += char; + i++; + } + } + return out; +} diff --git a/libs/domain/src/lib/transloco-brace-scan.spec.ts b/libs/domain/src/lib/transloco-brace-scan.spec.ts index cccb005..6c51055 100644 --- a/libs/domain/src/lib/transloco-brace-scan.spec.ts +++ b/libs/domain/src/lib/transloco-brace-scan.spec.ts @@ -4,6 +4,7 @@ import { convertTranslocoPlaceholders, expandPlaceholderOnlyBranchBodies, hasUnbundlableBranchBody, + maskTranslocoPlaceholders, } from './transloco-brace-scan'; interface ConversionFixture { @@ -605,6 +606,34 @@ describe('convertTranslocoPlaceholders', () => { }); }); +describe('maskTranslocoPlaceholders', () => { + it('blanks the placeholder braces without changing length', () => { + expect(maskTranslocoPlaceholders('Hello {{ name }}!')).toBe('Hello { name }!'); + expect(maskTranslocoPlaceholders('{{name}}')).toBe('{ name }'); + }); + + it('returns a value without placeholders unchanged', () => { + expect(maskTranslocoPlaceholders('')).toBe(''); + expect(maskTranslocoPlaceholders('{count, plural, one {# item} other {# items}}')).toBe( + '{count, plural, one {# item} other {# items}}', + ); + }); + + for (const fixture of [...FIXTURES, ...CONSUMER_FIXTURES]) { + it(`keeps length and parses like the converted value: ${fixture.description}`, () => { + const masked = maskTranslocoPlaceholders(fixture.input); + expect(masked).toHaveLength(fixture.input.length); + + const converted = parseICU(convertTranslocoPlaceholders(fixture.input)); + const maskedTokens = parseICU(masked); + expect(maskedTokens === null).toBe(converted === null); + if (converted !== null && maskedTokens !== null) { + expect(stripContext(maskedTokens)).toEqual(stripContext(converted)); + } + }); + } +}); + describe('hasUnbundlableBranchBody', () => { for (const fixture of DETECTOR_FIXTURES.filter((candidate) => candidate.flagged)) { it(`reports ${fixture.description}`, () => { diff --git a/libs/domain/src/lib/transloco-brace-scan.ts b/libs/domain/src/lib/transloco-brace-scan.ts index 406400e..96ce640 100644 --- a/libs/domain/src/lib/transloco-brace-scan.ts +++ b/libs/domain/src/lib/transloco-brace-scan.ts @@ -355,6 +355,57 @@ export function convertTranslocoPlaceholders(value: string): string { return scanIcuBraces(value, true, convertBraceRun).text; } +/** + * Converts Transloco double-brace placeholders to ICU single-brace placeholders without + * changing the length of the value, so every index into the result is also an index + * into the input. + * + * It recognizes exactly the runs `convertTranslocoPlaceholders` converts. Instead of + * deleting the placeholder's extra `{` and `}`, it overwrites each with a space, which + * ICU accepts inside an argument: `{{ name }}` becomes `{ name }`. Parsing the result + * yields the same tokens as parsing the converted value, but with `ctx.offset` values + * that point into the raw input. Callers that report positions — highlighting, in-place + * replacement — parse this form rather than the converted one. + * + * @param value - The translation string, potentially using Transloco syntax + * @returns A string of the same length with genuine `{{ name }}` placeholders reduced to + * single-brace ICU arguments + * + * @example + * ```typescript + * maskTranslocoPlaceholders('Hello {{ name }}!'); + * // → 'Hello { name }!' + * + * maskTranslocoPlaceholders('{nameExists, select, hasName {{name}} other {this item}}'); + * // → unchanged — the first brace opens the `hasName` branch body + * ``` + */ +export function maskTranslocoPlaceholders(value: string): string { + if (!value.includes('{{')) { + return value; + } + + /** Blanks the placeholder's own brace pair, leaving structural braces and the name in place. */ + const maskBraceRun = (start: number, openBraces: OpenBrace[]): BraceAction | null => { + const run = readBraceRun(value, start); + const structuralBraces = run && opensSubMessageBody(value, start, openBraces) ? 1 : 0; + + if (!run || run.openCount - structuralBraces < 2) { + return null; + } + + applyBraceRun(openBraces, run); + + const inner = value.substring(start + run.openCount, run.endIndex - run.closeCount); + return { + text: `${'{'.repeat(run.openCount - 1)} ${inner} ${'}'.repeat(run.closeCount - 1)}`, + length: run.endIndex - start, + }; + }; + + return scanIcuBraces(value, true, maskBraceRun).text; +} + /** * Transloco's own interpolation matcher: `{{`, no braces between, `}}`. * Sticky, so it can be anchored at a candidate `{{`.