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) {
+
+
spellcheck
+
+
+ {{ 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 @@