Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e4fa279
feat(domain): add preferred terminology rules and matching
SimonNodel-AI Sep 22, 2026
993deb6
fix(domain): reject brace and angle-bracket characters in preferred t…
SimonNodel-AI Sep 22, 2026
ecb0c12
feat(core): load and write the preferred terminology file
SimonNodel-AI Sep 22, 2026
697013f
fix(core): revalidate cached preferred terminology against file stat
SimonNodel-AI Sep 22, 2026
6bd31e5
feat(cli): add preferred-terminology command
SimonNodel-AI Sep 22, 2026
ec0b0f5
feat(cli): list preferred-terminology in the generated skill
SimonNodel-AI Sep 22, 2026
c405d16
feat(core): report preferred terminology warnings in validate
SimonNodel-AI Sep 22, 2026
023a097
feat(core): warn about preferred terminology on base-locale import
SimonNodel-AI Sep 22, 2026
c12b487
feat(cli): warn about preferred terminology on add and edit
SimonNodel-AI Sep 22, 2026
9df99db
fix(cli): import into a collection's own base locale
SimonNodel-AI Sep 22, 2026
2ff7796
feat(api): read and write preferred terminology through /config
SimonNodel-AI Sep 22, 2026
889c21f
feat(tracker): manage preferred terminology in Settings
SimonNodel-AI Sep 22, 2026
2a96e02
feat(tracker): suggest preferred terminology in the resource editor
SimonNodel-AI Sep 22, 2026
6c9f086
style(tracker): make the preferred-term fix read as a button
SimonNodel-AI Sep 23, 2026
f358790
docs: document preferred terminology
SimonNodel-AI Sep 22, 2026
afc14b9
fix(core): never throw when the preferred terminology file cannot be …
SimonNodel-AI Sep 23, 2026
18c4e79
fix(domain): keep "#" literal when applying a preferred term in a plu…
SimonNodel-AI Sep 23, 2026
4725bcc
fix(domain): mask tags with quoted ">" and non-ASCII names in preferr…
SimonNodel-AI Sep 23, 2026
dd6c339
fix(cli): offer the collection's locales when import prompts for a ta…
SimonNodel-AI Sep 23, 2026
93e4cc1
fix(tracker): escape the braces in the invalid-character rule error
SimonNodel-AI Sep 23, 2026
865e7da
fix(tracker): lock settings editing until config loads and while a sa…
SimonNodel-AI Sep 23, 2026
629d7f9
fix(core): report a non-string preferredTerminologyFile as a load error
SimonNodel-AI Sep 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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.

Expand Down
208 changes: 202 additions & 6 deletions apps/api/src/app/config/config.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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(() => {
Expand Down
61 changes: 55 additions & 6 deletions apps/api/src/app/config/config.controller.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,50 @@
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) {}

@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 } {
Expand All @@ -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);
}
Loading
Loading