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/i18n/browser/translationEditor/preferredTerm/resource_entries.json b/apps/tracker/src/i18n/browser/translationEditor/preferredTerm/resource_entries.json
new file mode 100644
index 0000000..caf7941
--- /dev/null
+++ b/apps/tracker/src/i18n/browser/translationEditor/preferredTerm/resource_entries.json
@@ -0,0 +1,22 @@
+{
+ "messageX": {
+ "source": "Preferred terminology: consider “{preferred}” instead of “{discouraged}”.",
+ "comment": "Advisory under the base-locale value in the resource editor. {{ preferred }} is the configured preferred term, {{ discouraged }} the discouraged term found in the value. Keep the curly quotes around the terms.",
+ "tags": ["browser"],
+ "es": "Preferred terminology: consider “{preferred}” instead of “{discouraged}”.",
+ "fr-ca": "Preferred terminology: consider “{preferred}” instead of “{discouraged}”.",
+ "ru": "Preferred terminology: consider “{preferred}” instead of “{discouraged}”.",
+ "ja": "Preferred terminology: consider “{preferred}” instead of “{discouraged}”.",
+ "de": "Preferred terminology: consider “{preferred}” instead of “{discouraged}”."
+ },
+ "useX": {
+ "source": "Use “{preferred}”",
+ "comment": "Button in the preferred-terminology advisory that replaces the discouraged term in the base value with {{ preferred }}. Does not save.",
+ "tags": ["browser"],
+ "es": "Use “{preferred}”",
+ "fr-ca": "Use “{preferred}”",
+ "ru": "Use “{preferred}”",
+ "ja": "Use “{preferred}”",
+ "de": "Use “{preferred}”"
+ }
+}
diff --git a/apps/tracker/src/i18n/browser/translationEditor/preferredTerm/tracker_meta.json b/apps/tracker/src/i18n/browser/translationEditor/preferredTerm/tracker_meta.json
new file mode 100644
index 0000000..1c77c93
--- /dev/null
+++ b/apps/tracker/src/i18n/browser/translationEditor/preferredTerm/tracker_meta.json
@@ -0,0 +1,62 @@
+{
+ "messageX": {
+ "en": {
+ "checksum": "b30fb379202e725826c9ce2ae310b5b3"
+ },
+ "es": {
+ "checksum": "b30fb379202e725826c9ce2ae310b5b3",
+ "baseChecksum": "b30fb379202e725826c9ce2ae310b5b3",
+ "status": "new"
+ },
+ "fr-ca": {
+ "checksum": "b30fb379202e725826c9ce2ae310b5b3",
+ "baseChecksum": "b30fb379202e725826c9ce2ae310b5b3",
+ "status": "new"
+ },
+ "ru": {
+ "checksum": "b30fb379202e725826c9ce2ae310b5b3",
+ "baseChecksum": "b30fb379202e725826c9ce2ae310b5b3",
+ "status": "new"
+ },
+ "ja": {
+ "checksum": "b30fb379202e725826c9ce2ae310b5b3",
+ "baseChecksum": "b30fb379202e725826c9ce2ae310b5b3",
+ "status": "new"
+ },
+ "de": {
+ "checksum": "b30fb379202e725826c9ce2ae310b5b3",
+ "baseChecksum": "b30fb379202e725826c9ce2ae310b5b3",
+ "status": "new"
+ }
+ },
+ "useX": {
+ "en": {
+ "checksum": "8c36724b30304c5ac5779488d6712554"
+ },
+ "es": {
+ "checksum": "8c36724b30304c5ac5779488d6712554",
+ "baseChecksum": "8c36724b30304c5ac5779488d6712554",
+ "status": "new"
+ },
+ "fr-ca": {
+ "checksum": "8c36724b30304c5ac5779488d6712554",
+ "baseChecksum": "8c36724b30304c5ac5779488d6712554",
+ "status": "new"
+ },
+ "ru": {
+ "checksum": "8c36724b30304c5ac5779488d6712554",
+ "baseChecksum": "8c36724b30304c5ac5779488d6712554",
+ "status": "new"
+ },
+ "ja": {
+ "checksum": "8c36724b30304c5ac5779488d6712554",
+ "baseChecksum": "8c36724b30304c5ac5779488d6712554",
+ "status": "new"
+ },
+ "de": {
+ "checksum": "8c36724b30304c5ac5779488d6712554",
+ "baseChecksum": "8c36724b30304c5ac5779488d6712554",
+ "status": "new"
+ }
+ }
+}
diff --git a/apps/tracker/src/testing/transloco-testing.module.ts b/apps/tracker/src/testing/transloco-testing.module.ts
index 3f67f31..4c1de0e 100644
--- a/apps/tracker/src/testing/transloco-testing.module.ts
+++ b/apps/tracker/src/testing/transloco-testing.module.ts
@@ -91,6 +91,9 @@ export function getTranslocoTestingModule(options: TranslocoTestingOptions = {})
'Entry name only — paste a full dotted key and its folder part moves to Location',
'browser.translationEditor.locationFromKeyX': 'Location set to {{ folder }} from the key you entered',
'browser.translationEditor.baseBadge': 'Base',
+ 'browser.translationEditor.preferredTerm.messageX':
+ 'Preferred terminology: consider “{{ preferred }}” instead of “{{ discouraged }}”.',
+ 'browser.translationEditor.preferredTerm.useX': 'Use “{{ preferred }}”',
'browser.translationEditor.localeTranslationX': '{{ locale }} Translation',
'browser.translationEditor.enterTranslationX': 'Enter {{ locale }} translation...',
'browser.translationEditor.baseValueRequired': 'Base translation is required',
From 6c9f086b9d09ece4237a597d5b3d9f13b559ae69 Mon Sep 17 00:00:00 2001
From: Simon Nodel
Date: Tue, 22 Sep 2026 20:10:32 -0700
Subject: [PATCH 14/22] style(tracker): make the preferred-term fix read as a
button
The bare amber text was mistaken for part of the advisory, so the
one-click fix went unnoticed. Give it a border, a tinted fill and
hover/active states.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../preferred-term-advisories.scss | 19 ++++++++++++-------
1 file changed, 12 insertions(+), 7 deletions(-)
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
index e172ee6..d46f338 100644
--- 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
@@ -45,14 +45,15 @@
color: var(--color-text-secondary);
}
-// The fix rides the first line, like the key collision's "Open existing".
+// 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: 0;
- padding: 0 2px;
- border: 0;
+ 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: none;
+ background: color-mix(in srgb, var(--color-warning) 18%, transparent);
color: inherit;
font-family: inherit;
font-size: inherit;
@@ -62,12 +63,16 @@
cursor: pointer;
&:hover {
- text-decoration: underline;
+ 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;
- text-decoration: underline;
+ }
+
+ &:active {
+ background: color-mix(in srgb, var(--color-warning) 36%, transparent);
}
}
From f358790fb6c4b9190399f78ff9edbb2f6b45d79d Mon Sep 17 00:00:00 2001
From: Simon Nodel
Date: Tue, 22 Sep 2026 07:41:52 -0700
Subject: [PATCH 15/22] docs: document preferred terminology
Co-Authored-By: Claude Opus 5 (1M context)
---
README.md | 9 +-
architecture-docs/api.md | 4 +-
architecture-docs/feature-matrix.md | 4 +
architecture-docs/glossary.md | 14 ++
docs/cli.md | 9 +-
docs/features/glossary.md | 2 +
docs/features/import.md | 2 +
docs/features/preferred-terminology.md | 243 +++++++++++++++++++++++++
docs/features/protected-terms.md | 2 +
docs/features/validate.md | 2 +-
10 files changed, 286 insertions(+), 5 deletions(-)
create mode 100644 docs/features/preferred-terminology.md
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/architecture-docs/api.md b/architecture-docs/api.md
index 2ed5eab..c8fb35c 100644
--- a/architecture-docs/api.md
+++ b/architecture-docs/api.md
@@ -35,8 +35,8 @@ All paths are relative to the `/api` global prefix. URL path parameters that con
| Method | Path | Purpose | Request DTO | Response DTO |
|--------|------|---------|-------------|--------------|
-| `GET` | `/config` | Read global config and all collection configs, with protected terms resolved from their files | — | `LingoTrackerConfigDto` |
-| `PUT` | `/config` | Update the writable top-level globals. Today that is `protectedTerms` alone. The handler writes it to the global protected-terms **file**, and leaves `.lingo-tracker.json` untouched. `collections`, `locales`, and `baseLocale` stay excluded on purpose. | `UpdateConfigDto` | `{ message: string }` |
+| `GET` | `/config` | Read global config and all collection configs, with protected terms resolved from their files. Also carries the preferred terminology rules, the rule file path, and any load error or missing-file warning. | — | `LingoTrackerConfigDto` |
+| `PUT` | `/config` | Update the writable top-level globals: `protectedTerms` and `preferredTerminology`. The handler writes each one to its own **file**, and leaves `.lingo-tracker.json` untouched. `preferredTerminology` is the full rule list. The server validates it again and returns `400` with per-row errors when a rule is invalid. `collections`, `locales`, and `baseLocale` stay excluded on purpose. | `UpdateConfigDto` | `{ message: string }` |
### Collections
diff --git a/architecture-docs/feature-matrix.md b/architecture-docs/feature-matrix.md
index b773ab1..33814a8 100644
--- a/architecture-docs/feature-matrix.md
+++ b/architecture-docs/feature-matrix.md
@@ -67,6 +67,10 @@ Each cell shows whether the operation is supported (`Yes`), not supported (`—`
| List the terms that apply (global or per collection) | Yes (`protected-terms --list`) | Yes (resolved on `GET /config`) | Yes (Settings page, and collection edit dialog) |
| Add, remove, or replace terms | Yes (`protected-terms --add/--remove/--set`) | Yes (`PUT /config`, and `PUT /collections/:name`) | Yes (chip editors. Collection chips need a terms file first.) |
| Name the terms file for a scope | Yes (`protected-terms --file`) | Yes (`protectedTermsFile` on `PUT /collections/:name`) | — (shows the resolved path, read-only) |
+| **[Preferred Terminology](glossary.md#preferred-terminology)** | | | |
+| List the rules | Yes (`preferred-terminology --list`) | Yes (`GET /config`, with the file path and any load error) | Yes (Settings page) |
+| Add, edit, or remove rules | Yes (`preferred-terminology --add/--remove`. `--add` replaces an existing rule.) | Yes (`PUT /config` with the full rule list. Invalid rules return `400` with per-row errors.) | Yes (Settings page rule table) |
+| Warn about discouraged terms in base-locale values | Yes (`add-resource`, `edit-resource`, base-locale `import`, `validate`) | — (create and update responses carry no findings) | Yes (translation editor notes, with a "Use …" fix that does not save) |
| **Validation** | | | |
| Validate all resources (CI gate) | Yes (`validate`) | — | — |
| View resource status per locale | — | — | Yes (status badge per locale row in item) |
diff --git a/architecture-docs/glossary.md b/architecture-docs/glossary.md
index 86b5271..bab65df 100644
--- a/architecture-docs/glossary.md
+++ b/architecture-docs/glossary.md
@@ -87,6 +87,20 @@ Explained in context: [`domain-and-data-model.md`](domain-and-data-model.md)
## P
+### Preferred Terminology
+
+A global list of rules. Each rule maps a **discouraged** source-language term to the **preferred** term, with an optional `reason`. LingoTracker warns when a base-locale value uses a discouraged term, and suggests the preferred one. It never blocks. Only an unreadable rule file fails `validate`.
+
+The rules are project configuration rather than resource data. They live in a standalone JSON file, a bare array of rule objects. `.lingo-tracker.json` names that file with `preferredTerminologyFile`. Omit the setting and the rules fall back to `.lingo-tracker-preferred-terminology.json` beside the config. Collections cannot override the rules.
+
+Matching is case-insensitive and whole-word, and it covers only the text a reader sees. ICU arguments and selectors, Transloco placeholders, and tags are skipped. The pure rule and matching functions live in `libs/domain`, so the Tracker UI and core share them.
+
+Contrast with [Protected Term](#protected-term), which keeps a word unchanged in translations and blocks imports that alter it.
+
+Explained in context: [`docs/features/preferred-terminology.md`](../docs/features/preferred-terminology.md)
+
+---
+
### Protected Term
A word that must stay unchanged through translation. A brand name, a product name, or a piece of jargon all qualify. `iPhone` stays `iPhone` in every locale.
diff --git a/docs/cli.md b/docs/cli.md
index 1d052db..10b22f8 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -343,6 +343,8 @@ lingo-tracker preferred-terminology --remove "Expenditure"
- LingoTracker rejects invalid rules and leaves the file untouched. For example, a preferred term that is itself discouraged is rejected.
- A malformed rules file makes `--list` report the error. `--add` and `--remove` refuse to run until you fix it, so they never overwrite it.
+The [Preferred Terminology](./features/preferred-terminology.md) page explains how matching works and where the warnings appear.
+
---
### delete-collection
@@ -525,6 +527,7 @@ lingo-tracker add-resource \
- In interactive mode, you'll be prompted if you want to provide translations for each configured locale
- Resources are placed in the appropriate folder based on the key and optional `--target-folder`
- If a translation's checksum matches the base value's checksum, the status will automatically be set to `new` regardless of the provided status
+- After it stores the resource, the command prints a warning for each [preferred terminology](./features/preferred-terminology.md) rule the base value breaks. The warnings never change the exit code.
---
@@ -660,6 +663,7 @@ lingo-tracker edit-resource \
- Updating `--base-value` triggers a checksum update and marks all other existing translations as `stale`.
- Updating a locale value sets its status to `translated` and updates its checksum.
- If no changes are detected (values match existing), the command reports "No changes detected".
+- When the command changes the base value, it prints a warning for each [preferred terminology](./features/preferred-terminology.md) rule the new value breaks. The warnings never change the exit code.
---
@@ -1331,7 +1335,9 @@ A value that does not compile as ICU for its own locale is a failure whatever it
**Exit Codes:**
- `0` - All validations passed (all resources verified)
-- `1` - Validation failures found (new/stale resources, translated without `--allow-translated`, or values that fail to compile as ICU)
+- `1` - Validation failures found (new/stale resources, translated without `--allow-translated`, or values that fail to compile as ICU), or the preferred terminology file exists but cannot be loaded
+
+[Preferred terminology](./features/preferred-terminology.md) findings in base-locale values are warnings. They never change the exit code.
**When to Use Validate:**
@@ -1999,6 +2005,7 @@ All commands read from `.lingo-tracker.json` in the project root. This file is c
- **Per-collection fields** can override global settings for specific collections
- **`readOnly`** (optional, per-collection) - When `true`, resource mutations to this collection are blocked across the CLI, API, and UI. The collection can still be unregistered and its config entry edited. Defaults to `true` for `node_modules` paths when added via `add-collection`. See [add-collection](#add-collection) for details.
- **`tokenCasing`** (optional) - Controls the casing style for generated type token keys. Accepts `"upperCase"` (default, SCREAMING_SNAKE_CASE) or `"camelCase"`. Can be set globally or per-bundle. See [Bundle Type Generation](./features/bundle-type-generation.md) for details. Precedence: CLI flag `--token-casing` > per-bundle config > global config > default (`"upperCase"`)
+- **`preferredTerminologyFile`** (optional, global only) - Path to the JSON file that holds preferred terminology rules. LingoTracker resolves the path against the directory that contains `.lingo-tracker.json`. Omit it and the rules fall back to `.lingo-tracker-preferred-terminology.json`. See [Preferred Terminology](./features/preferred-terminology.md) for details.
- **`protectedTermsFile`** (optional, global and per-collection) - Path to a JSON file that holds protected terms. LingoTracker resolves the path against the directory that contains `.lingo-tracker.json`. Omit it globally and the list falls back to `.lingo-tracker-protected-terms.json`. A collection has no default, so a collection without this setting contributes no terms of its own. LingoTracker adds a collection's terms to the global ones. See [Protected Terms](./features/protected-terms.md) for details.
---
diff --git a/docs/features/glossary.md b/docs/features/glossary.md
index 7936ff7..285edb7 100644
--- a/docs/features/glossary.md
+++ b/docs/features/glossary.md
@@ -9,6 +9,8 @@ The Glossary feature extracts a glossary of relevant translations from a block o
Given a paragraph of base-locale text, the `glossary` command pulls out the meaningful terms, finds the matching translation entries, and writes a JSON glossary containing each term's translations across all locales. That glossary can be handed to whoever (or whatever) translates the help content.
+The glossary is a reference that the command generates on demand. It checks nothing. To keep words unchanged in translations, use [protected terms](./protected-terms.md). To flag discouraged wording in your base-locale text, use [preferred terminology](./preferred-terminology.md).
+
## Usage
```bash
diff --git a/docs/features/import.md b/docs/features/import.md
index 254a5ef..a05ae88 100644
--- a/docs/features/import.md
+++ b/docs/features/import.md
@@ -223,6 +223,8 @@ The warnings are advisory. The value is imported as-is, nothing is skipped or fa
- **A broken rule file** (invalid JSON or invalid rules) adds one warning, `Preferred terminology checks skipped: …`, and the import continues without the check.
- **A missing file** at the default path means no rules. A missing file named explicitly by `preferredTerminologyFile` adds one warning.
+The [Preferred Terminology](./preferred-terminology.md) page explains the rules and how matching works.
+
## ICU Format Auto-Fixing
LingoTracker automatically fixes common ICU message format placeholder errors made by translators:
diff --git a/docs/features/preferred-terminology.md b/docs/features/preferred-terminology.md
new file mode 100644
index 0000000..61e0fba
--- /dev/null
+++ b/docs/features/preferred-terminology.md
@@ -0,0 +1,243 @@
+---
+title: Preferred Terminology
+sidebar_position: 7
+---
+
+# Preferred Terminology
+
+Preferred terminology is a list of source-language terms your product no longer uses. Each rule maps a **discouraged term** to the **preferred term** that replaced it. A rule can also carry a reason.
+
+LingoTracker checks base-locale values against these rules. When a value uses a discouraged term, LingoTracker warns and suggests the preferred term. It never blocks a save, an import, or a release. An older term can still be correct in a quotation or a historical note, so the author decides.
+
+The warnings appear in five places:
+
+- The **resource editor** in the web UI shows a note under the base value, with a button that applies the preferred term.
+- **`add-resource`** and **`edit-resource`** print a warning after they store the value.
+- A **base-locale import** adds a warning to the import summary.
+- **`validate`** lists every finding in its own section.
+- **Settings** in the web UI, and the `preferred-terminology` CLI command, edit the rules themselves.
+
+## How it differs from protected terms and the glossary
+
+LingoTracker has three features that deal with terminology. They solve different problems.
+
+| | Preferred terminology | [Protected terms](./protected-terms.md) | [Translation glossary](./glossary.md) |
+|---|---|---|---|
+| What it is | Rules that map a discouraged source term to a preferred one | Words that must stay unchanged in every translation, such as `iPhone` | A JSON file of the app's existing translations for the terms found in a block of help text |
+| Who maintains it | Your team, in Settings, with the CLI, or in the rule file | Your team, in Settings, with the CLI, or in the terms file | Nobody. The `glossary` command generates it on demand. |
+| What it checks | Base-locale values that use a discouraged term | Imported translations that dropped or altered a term from the source | Nothing. Translators use it as a reference. |
+| Does it block? | No. Findings are warnings. Only an unreadable rule file fails `validate`. | Yes. Import rejects a translation that altered a protected term. | No |
+| Locales it looks at | The base locale only | Target locales | Reads base-locale values, and outputs target-locale translations |
+| Scope | One global file | A global file, plus an optional file per collection | One run of the command, optionally limited to one collection |
+
+In short, preferred terminology changes the wording of your source text. Protected terms keep words intact through translation. The glossary helps translators reuse words that the app already translates.
+
+## The file
+
+The rule file holds a bare JSON array. Each rule has a `discouraged` term, a `preferred` term, and an optional `reason`.
+
+```json
+[
+ {
+ "discouraged": "E-mail",
+ "preferred": "email"
+ },
+ {
+ "discouraged": "Expenditure",
+ "preferred": "Investment",
+ "reason": "Current planning term."
+ },
+ {
+ "discouraged": "Log-in",
+ "preferred": "sign in",
+ "reason": "Match the product UI."
+ }
+]
+```
+
+The file lives at **`.lingo-tracker-preferred-terminology.json`** by default. That file sits beside `.lingo-tracker.json`. You do not need to create it. Adding your first rule creates it.
+
+To keep the rules somewhere else, name the path in your configuration with `preferredTerminologyFile`.
+
+```json
+{
+ "baseLocale": "en",
+ "locales": ["en", "es"],
+ "preferredTerminologyFile": "config/preferred-terminology.json",
+ "collections": {
+ "app": { "translationsFolder": "src/i18n" }
+ }
+}
+```
+
+LingoTracker resolves a relative path against the directory that holds `.lingo-tracker.json`. It uses an absolute path as written.
+
+There is one global rule file. A collection cannot add rules or override them. A collection with its own `baseLocale` is still checked, in that base locale.
+
+### How LingoTracker writes the file
+
+Every write replaces the whole file. The CLI and the web UI write it the same way.
+
+- LingoTracker sorts the rules by discouraged term, ignoring case. Adding a rule therefore produces a small diff.
+- It trims spaces from every field.
+- It drops a `reason` that is empty.
+- It indents with two spaces and ends the file with a newline.
+
+LingoTracker validates the whole list before it writes. If any rule is invalid, it writes nothing and leaves the file as it was.
+
+The API server re-reads the file when the file changes on disk. A hand edit or a `git pull` therefore takes effect without a restart.
+
+## Rule validation
+
+LingoTracker rejects a rule list that contains any of the problems below. It compares terms after trimming, and it ignores case. So `Email → email` counts as a self-mapping.
+
+| Problem | Example | Why LingoTracker rejects it |
+|---|---|---|
+| Empty term | `"preferred": ""` | Both terms are required. |
+| Not text | `"preferred": 42`, or a row that is not an object | Terms and the reason must be strings. |
+| Invalid character | `Account {name}` | A term cannot contain `{`, `}`, `<`, or `>`. LingoTracker never matches inside ICU syntax or tags, so such a discouraged term could never match. Such a preferred term would break the message when applied. |
+| Duplicate | `Expenditure` and `expenditure` in two rows | Each discouraged term can appear only once. LingoTracker reports the later row. |
+| Self-mapping | `Email → email` | The preferred term must differ from the discouraged term. |
+| Chain | `Log-in → Login` and `Login → sign in` | The preferred term is itself discouraged. Map `Log-in` straight to `sign in`. |
+| Cycle | `Sign-on → Login` and `Login → Sign-on` | Following the rules leads back to the start. |
+| Preferred contains a discouraged term | `Email → Email address`, or `Account → E-mail account` when `E-mail` is discouraged | Applying the suggestion would produce text that is flagged again. LingoTracker checks for a whole-word match, and it includes the rule's own discouraged term. |
+
+The reason is free text. LingoTracker only checks that it is a string.
+
+## What counts as a match
+
+LingoTracker matches a discouraged term as a whole word or a whole phrase, ignoring case.
+
+- **Letters, digits, and `_` are word characters.** Any other character ends a word, including `-` and `'`.
+- **No plurals or word stems.** A rule for `Expenditure` does not match `Expenditures`. Add a second rule if you need the plural.
+- **One rule, one warning per value.** A term that appears three times in one value produces one finding.
+
+With a rule for `Expenditure`:
+
+| Value | Match? |
+|---|---|
+| `Capital expenditure` | Yes. Case does not matter. |
+| `expenditure-report` | Yes. `-` separates words. |
+| `the expenditure's total` | Yes. `'` separates words. |
+| `Expenditures` | No. It is a longer word. |
+| `ExpenditureType` | No. It is a longer word. |
+| `expenditure_id` | No. `_` joins words. |
+
+LingoTracker checks only the text a reader sees. It skips these parts of a value:
+
+- ICU argument names, formats, and styles, such as `{expenditure}` or `{amount, number, currency}`
+- `select` and `plural` selectors, and the `#` symbol. The text inside each branch is still checked.
+- Transloco placeholders, such as `{{ expenditure }}`
+- HTML and XML tags, including their attributes. The text between tags is still checked.
+
+So `Expenditure for {expenditure}` gets one finding, for the first word only.
+
+When a value is not valid ICU, LingoTracker skips every `{…}` span and every tag, and checks the rest.
+
+LingoTracker checks the raw text, so it does not undo ICU quoting. A term that contains an apostrophe does not match a doubled `''` in the stored value.
+
+## Resource editor
+
+The editor checks the base-locale value while you type. After a short pause, it shows one amber note per rule the value breaks. The note reads `Preferred terminology: consider "Investment" instead of "Expenditure".` The rule's reason appears below it.
+
+The editor checks an existing value as soon as the dialog opens. Translations in other locales are not checked.
+
+Each note has a **Use "…"** button. It replaces every visible occurrence of the discouraged term with the preferred term.
+
+- The editor inserts the preferred term exactly as the rule spells it. For `Expenditure → Investment`, `capital expenditure` becomes `capital Investment`. Check the capitals before you save.
+- ICU arguments, placeholders, and tags stay unchanged.
+- The button changes the field only. You still save with **Save changes**, and you can still cancel.
+
+The editor hides the button when the resource is read-only.
+
+## Settings
+
+The **Preferred Terminology** section of **Settings** lists the rules as a table. Each row has a discouraged term, a preferred term, and an optional reason.
+
+- **Add rule** adds an empty row. The remove button deletes a row.
+- Settings validates the rules as you edit. It shows the error under the field that caused it.
+- **Save** stays disabled while a row has an error. One **Save** stores both the protected terms and the preferred terminology.
+- The server validates the rules again before it writes the file.
+
+The section names the rule file it reads and writes.
+
+If the rule file exists but cannot be read, Settings shows an error with the details. No rules are in force until you fix the file. Saving from Settings replaces the file with the rules on screen.
+
+If `preferredTerminologyFile` names a file that does not exist, Settings says so. Saving creates the file.
+
+## CLI
+
+```bash
+# List the rules and the file that holds them
+lingo-tracker preferred-terminology --list
+
+# Add a rule. The file is created if it is absent.
+lingo-tracker preferred-terminology --add "Expenditure" --preferred "Investment" --reason "Current planning term."
+
+# Replace an existing rule. Leaving out --reason clears the old reason.
+lingo-tracker preferred-terminology --add "expenditure" --preferred "Spending"
+
+# Remove a rule
+lingo-tracker preferred-terminology --remove "Expenditure"
+```
+
+`--add` and `--remove` find an existing rule by its discouraged term, ignoring case. `--add` on an existing term replaces the whole rule.
+
+If the new list breaks a validation rule, the command prints each problem, exits with code `1`, and leaves the file unchanged. If the file cannot be read, `--add` and `--remove` refuse to run, so they never overwrite it.
+
+The [CLI reference](../cli.md#preferred-terminology) holds the full option list.
+
+## Adding and editing resources
+
+`add-resource` stores the value first, and then prints one warning per rule the base value breaks.
+
+```
+✅ Resource added: budget.title
+⚠️ Preferred terminology: consider "Investment" instead of "Expenditure"
+ Current planning term.
+```
+
+`edit-resource` does the same, but only when the command changes the base value. Changing a comment, tags, or a translation prints no terminology warnings.
+
+The warnings never change the exit code.
+
+## Import
+
+A base-locale import checks every value it creates or updates. Only the `migration` strategy can import into the base locale. Each finding adds a warning to the import summary.
+
+```
+Preferred terminology: key "budget.title" — consider "Investment" instead of "Expenditure". Current planning term.
+```
+
+The import still writes the value. Nothing is skipped or failed, and the exit code does not change. A dry run reports the same warnings.
+
+Imports into a target locale are not checked. The [Import](./import.md#preferred-terminology-warnings) page has the details.
+
+## Validate
+
+`validate` scans every base-locale value in every collection. It lists the findings in a **Preferred terminology warnings** section.
+
+```
+⚠️ Preferred terminology warnings (2):
+──────────────────────────────────────────────────
+ [main] budget.summary: consider "Investment" instead of "Expenditure"
+ Current planning term.
+ [main] contact.help: consider "email" instead of "E-mail"
+```
+
+- Findings are warnings. They count towards the warning total, and they never change the exit code.
+- `validate` reports each key and rule once. It does not repeat a finding per target locale or per occurrence.
+- `validate` has no flag to turn the check off. Remove the rules to stop it.
+- An unreadable rule file is a **failure**, and `validate` exits with code `1`. Without this, a typo in the file would switch the check off in CI without anyone noticing.
+
+The [Validate](./validate.md#preferred-terminology) page has the details.
+
+## Missing or broken files
+
+| Situation | Editor, `add-resource`, `edit-resource`, import | `validate` |
+|---|---|---|
+| No file at the default path | No rules, no message. This is the normal state before the first rule. | No rules, no message |
+| No file at the path in `preferredTerminologyFile` | No rules. The CLI prints a warning, and Settings shows a note. A path to nothing is usually a typo. | A warning, then no rules |
+| The file is not valid JSON, is not an array, or has an invalid rule | The check is skipped. The CLI prints a warning. The editor stays silent, and Settings shows the error. | A failure, with exit code `1` |
+
+LingoTracker treats an unreadable file differently in `validate` on purpose. Authoring tools should keep working while someone fixes the file. The CI check should not pass while no rules are in force.
diff --git a/docs/features/protected-terms.md b/docs/features/protected-terms.md
index 31ac9dd..f302903 100644
--- a/docs/features/protected-terms.md
+++ b/docs/features/protected-terms.md
@@ -12,6 +12,8 @@ LingoTracker applies the list in two places.
- **Export** marks each exported string with the protected terms found in its source. Translators and machine-translation services then see which words to leave alone.
- **Import** rejects an incoming translation when a protected term from the source is missing from it.
+Protected terms are not [preferred terminology](./preferred-terminology.md). Preferred terminology suggests better wording for your base-locale text, and it only warns. Protected terms keep words intact in translations, and import enforces them.
+
The terms live in a **JSON file** of their own, outside `.lingo-tracker.json`. A terminology list grows to hundreds of entries. It also changes on a different schedule from the rest of your configuration. A separate file keeps your configuration diffs short, and it lets reviewers read the terminology on its own.
## The file
diff --git a/docs/features/validate.md b/docs/features/validate.md
index 6af9c7b..5671db7 100644
--- a/docs/features/validate.md
+++ b/docs/features/validate.md
@@ -113,7 +113,7 @@ It applies to the base locale only — translations keep their own categories, w
### Preferred Terminology
-Each collection's base-locale values are scanned for discouraged terms from the preferred terminology file (`.lingo-tracker-preferred-terminology.json` beside `.lingo-tracker.json`, or the file named by `preferredTerminologyFile`). Manage the rules with `lingo-tracker preferred-terminology`.
+Each collection's base-locale values are scanned for discouraged terms from the preferred terminology file (`.lingo-tracker-preferred-terminology.json` beside `.lingo-tracker.json`, or the file named by `preferredTerminologyFile`). Manage the rules with `lingo-tracker preferred-terminology`. The [Preferred Terminology](./preferred-terminology.md) page explains the rules and how matching works.
- **Warnings only.** A finding suggests better wording; it never fails validation or changes the exit code.
- **Once per key and rule.** A term used three times in one value, in a project with five target locales, is one warning.
From afc14b9e22103e9390ad6924a9d39b55267fb6bd Mon Sep 17 00:00:00 2001
From: Simon Nodel
Date: Tue, 22 Sep 2026 20:47:50 -0700
Subject: [PATCH 16/22] fix(core): never throw when the preferred terminology
file cannot be read
A stat failure other than ENOENT (ENOTDIR, EACCES, ELOOP) used to escape
loadPreferredTerminology. It now comes back as an in-band "cannot be
read" error, and read failures such as EISDIR are no longer reported as
invalid JSON. A stat failure after a successful write only drops the
cache entry.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../config/preferred-terminology-file.spec.ts | 26 +++++++-
.../lib/config/preferred-terminology-file.ts | 61 ++++++++++++++++---
2 files changed, 77 insertions(+), 10 deletions(-)
diff --git a/libs/core/src/lib/config/preferred-terminology-file.spec.ts b/libs/core/src/lib/config/preferred-terminology-file.spec.ts
index 28e8bf0..113f207 100644
--- a/libs/core/src/lib/config/preferred-terminology-file.spec.ts
+++ b/libs/core/src/lib/config/preferred-terminology-file.spec.ts
@@ -1,4 +1,4 @@
-import { mkdtempSync, readFileSync, rmSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs';
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
@@ -112,6 +112,30 @@ describe('preferred-terminology-file', () => {
expect(result.error).toContain(filePath);
});
+ it('reports a pointer through a regular file as an unreadable file, without throwing', () => {
+ write('somefile.json', '[]');
+ const config = baseConfig({ preferredTerminologyFile: 'somefile.json/rules.json' });
+
+ const result = loadPreferredTerminology(config, cwd);
+
+ expect(result.rules).toEqual([]);
+ expect(result.warning).toBeUndefined();
+ expect(result.error).toContain('cannot be read');
+ expect(result.error).toContain(join(cwd, 'somefile.json/rules.json'));
+ expect(result.error).toContain('ENOTDIR');
+ });
+
+ it('reports a directory at the file path as an unreadable file, not as invalid JSON', () => {
+ mkdirSync(join(cwd, DEFAULT_PREFERRED_TERMINOLOGY_FILENAME));
+
+ const result = loadPreferredTerminology(baseConfig(), cwd);
+
+ expect(result.rules).toEqual([]);
+ expect(result.error).toContain('cannot be read');
+ expect(result.error).not.toContain('not valid JSON');
+ expect(result.error).toContain('EISDIR');
+ });
+
it('reports a non-array payload as an error', () => {
write(DEFAULT_PREFERRED_TERMINOLOGY_FILENAME, '{ "rules": [] }');
diff --git a/libs/core/src/lib/config/preferred-terminology-file.ts b/libs/core/src/lib/config/preferred-terminology-file.ts
index edbaa6a..65346b3 100644
--- a/libs/core/src/lib/config/preferred-terminology-file.ts
+++ b/libs/core/src/lib/config/preferred-terminology-file.ts
@@ -23,7 +23,7 @@ export interface LoadPreferredTerminologyResult {
rules: PreferredTermRule[];
/** Absolute path of the file, whether or not it exists yet. */
filePath: string;
- /** Set when the file exists but cannot be used: malformed JSON, wrong shape, or invalid rules. */
+ /** Set when the file exists but cannot be used: unreadable, malformed JSON, wrong shape, or invalid rules. */
error?: string;
/** Set when an explicitly configured file does not exist. */
warning?: string;
@@ -94,7 +94,14 @@ export function loadPreferredTerminology(
): LoadPreferredTerminologyResult {
const filePath = resolvePreferredTerminologyFilePath(config, cwd);
- const stamp = readStamp(filePath);
+ let stamp: FileStamp | undefined;
+ try {
+ stamp = readStamp(filePath);
+ } catch (error) {
+ cache.delete(filePath);
+ return { rules: [], filePath, error: unreadableFileError(filePath, error) };
+ }
+
const cached = cache.get(filePath);
if (cached) {
if (stamp && sameStamp(cached.stamp, stamp)) {
@@ -114,12 +121,22 @@ export function loadPreferredTerminology(
return { rules: [], filePath };
}
+ let contents: string;
+ try {
+ contents = readFileSync(filePath, 'utf8');
+ } catch (error) {
+ return { rules: [], filePath, error: unreadableFileError(filePath, error) };
+ }
+
let parsed: unknown;
try {
- parsed = JSON.parse(readFileSync(filePath, 'utf8'));
+ parsed = JSON.parse(contents);
} catch (error) {
- const detail = error instanceof Error ? error.message : String(error);
- return { rules: [], filePath, error: `Preferred terminology file is not valid JSON: ${filePath} (${detail})` };
+ return {
+ rules: [],
+ filePath,
+ error: `Preferred terminology file is not valid JSON: ${filePath} (${errorDetail(error)})`,
+ };
}
if (!Array.isArray(parsed)) {
@@ -168,7 +185,13 @@ export function writePreferredTerminology(filePath: string, rules: readonly Pref
const sorted = sortPreferredTermRules(normalizePreferredTermRules(rules));
writeFileSync(filePath, `${JSON.stringify(sorted, null, 2)}\n`, 'utf8');
- const stamp = readStamp(filePath);
+ // The write succeeded; failing to stat it afterwards only costs the cache entry.
+ let stamp: FileStamp | undefined;
+ try {
+ stamp = readStamp(filePath);
+ } catch {
+ stamp = undefined;
+ }
if (stamp) {
cache.set(filePath, { rules: sorted, stamp });
} else {
@@ -181,10 +204,30 @@ function formatRuleErrors(errors: readonly PreferredTermRuleError[]): string {
return errors.map((error) => `row ${error.index + 1} ${error.field}: ${error.message}`).join('; ');
}
-/** `mtimeMs` and `size` of the file, or `undefined` when it does not exist. */
+function unreadableFileError(filePath: string, error: unknown): string {
+ return `Preferred terminology file cannot be read: ${filePath} (${errorDetail(error)})`;
+}
+
+function errorDetail(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+
+/**
+ * `mtimeMs` and `size` of the file, or `undefined` when it does not exist. Throws on any
+ * other stat failure (EACCES, ENOTDIR, ELOOP, ...); a pointer through a regular file is a
+ * misconfiguration, not a missing file.
+ */
function readStamp(filePath: string): FileStamp | undefined {
- const stats = statSync(filePath, { throwIfNoEntry: false });
- return stats ? { mtimeMs: stats.mtimeMs, size: stats.size } : undefined;
+ // Not `throwIfNoEntry: false`: Node folds ENOTDIR into "no entry" there too.
+ try {
+ const stats = statSync(filePath);
+ return { mtimeMs: stats.mtimeMs, size: stats.size };
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
+ return undefined;
+ }
+ throw error;
+ }
}
function sameStamp(a: FileStamp, b: FileStamp): boolean {
From 18c4e791a22d164c7a2500de00866d54006aafc1 Mon Sep 17 00:00:00 2001
From: Simon Nodel
Date: Tue, 22 Sep 2026 20:51:09 -0700
Subject: [PATCH 17/22] fix(domain): keep "#" literal when applying a preferred
term in a plural branch
Inside plural and selectordinal branches, and selects nested in them, a
bare "#" is the count, so a preferred term like "C#" read back as
"C". When the preferred term contains "#", the affected branch
text is now decoded, edited, and re-encoded with ICU apostrophe quoting,
so it parses back to exactly the preferred term. Elsewhere, and for
values that do not parse as ICU, the term is still inserted verbatim.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../src/lib/preferred-terminology.spec.ts | 69 ++++++++
libs/domain/src/lib/preferred-terminology.ts | 164 +++++++++++++++++-
2 files changed, 227 insertions(+), 6 deletions(-)
diff --git a/libs/domain/src/lib/preferred-terminology.spec.ts b/libs/domain/src/lib/preferred-terminology.spec.ts
index bdcbb0b..d59b917 100644
--- a/libs/domain/src/lib/preferred-terminology.spec.ts
+++ b/libs/domain/src/lib/preferred-terminology.spec.ts
@@ -1,3 +1,4 @@
+import { parse, type Token } from '@messageformat/parser';
import { describe, expect, it } from 'vitest';
import {
applyPreferredTerm,
@@ -412,4 +413,72 @@ describe('applyPreferredTerm', () => {
it('returns the value unchanged when nothing matches', () => {
expect(applyPreferredTerm('Expenditures', expenditure)).toBe('Expenditures');
});
+
+ describe('a preferred term containing "#"', () => {
+ const cSharp: PreferredTermRule = { discouraged: 'Old', preferred: 'C#' };
+
+ /** The literal text of the first branch of the top-level select/plural, as the ICU parser reads it. */
+ const firstBranch = (message: string): Token[] => {
+ const [root] = parse(message);
+ if (root?.type !== 'plural' && root?.type !== 'select' && root?.type !== 'selectordinal') {
+ throw new Error(`Not a select or plural: ${message}`);
+ }
+ return root.cases[0].tokens;
+ };
+
+ it('quotes "#" inside a plural branch so it stays literal', () => {
+ const result = applyPreferredTerm('{count, plural, other {Old item}}', cSharp);
+
+ expect(result).toBe("{count, plural, other {C'#' item}}");
+ expect(firstBranch(result)).toEqual([expect.objectContaining({ type: 'content', value: 'C# item' })]);
+ });
+
+ it('quotes "#" inside a selectordinal branch', () => {
+ const result = applyPreferredTerm('{n, selectordinal, other {Old}}', cSharp);
+
+ expect(firstBranch(result)).toEqual([expect.objectContaining({ type: 'content', value: 'C#' })]);
+ });
+
+ it('quotes "#" inside a select nested in a plural branch, where "#" is still the count', () => {
+ const result = applyPreferredTerm('{count, plural, other {{g, select, a {Old x} other {y}}}}', cSharp);
+
+ expect(result).toBe("{count, plural, other {{g, select, a {C'#' x} other {y}}}}");
+ const [select] = firstBranch(result);
+ expect(select?.type === 'select' ? select.cases[0].tokens : []).toEqual([
+ expect.objectContaining({ type: 'content', value: 'C# x' }),
+ ]);
+ });
+
+ it('inserts "#" verbatim outside plural branches', () => {
+ expect(applyPreferredTerm('Old item', cSharp)).toBe('C# item');
+ expect(applyPreferredTerm('{g, select, a {Old} other {x}}', cSharp)).toBe('{g, select, a {C#} other {x}}');
+ expect(applyPreferredTerm('{count, plural, other {# x}} Old', cSharp)).toBe('{count, plural, other {# x}} C#');
+ });
+
+ it('inserts verbatim when the value does not parse as ICU', () => {
+ expect(applyPreferredTerm('Old {count, plural, other {x}', cSharp)).toBe('C# {count, plural, other {x}');
+ });
+
+ it('keeps apostrophes around and inside the term literal', () => {
+ const quotedWord = applyPreferredTerm("{count, plural, other {'Old' item}}", cSharp);
+ expect(firstBranch(quotedWord)).toEqual([expect.objectContaining({ type: 'content', value: "'C#' item" })]);
+
+ const apostropheTerm = applyPreferredTerm('{count, plural, other {Old item}}', {
+ discouraged: 'Old',
+ preferred: "It's '#1'",
+ });
+ expect(firstBranch(apostropheTerm)).toEqual([
+ expect.objectContaining({ type: 'content', value: "It's '#1' item" }),
+ ]);
+ });
+
+ it('keeps existing quoting and the count placeholder elsewhere in the branch', () => {
+ const result = applyPreferredTerm("{count, plural, other {# Old, it''s '{x}'}}", cSharp);
+
+ expect(firstBranch(result)).toEqual([
+ expect.objectContaining({ type: 'octothorpe' }),
+ expect.objectContaining({ type: 'content', value: " C#, it's {x}" }),
+ ]);
+ });
+ });
});
diff --git a/libs/domain/src/lib/preferred-terminology.ts b/libs/domain/src/lib/preferred-terminology.ts
index 54eaa6e..0abb878 100644
--- a/libs/domain/src/lib/preferred-terminology.ts
+++ b/libs/domain/src/lib/preferred-terminology.ts
@@ -467,14 +467,23 @@ export function findPreferredTermFindings(value: string, rules: readonly Preferr
}
/**
- * Replaces every visible occurrence of the rule's discouraged term with `rule.preferred`,
- * verbatim. Arguments, placeholders and tags are never touched. Returns `value`
- * unchanged when nothing matches.
+ * Replaces every visible occurrence of the rule's discouraged term with `rule.preferred`.
+ * Arguments, placeholders and tags are never touched. Returns `value` unchanged when
+ * nothing matches.
+ *
+ * The preferred term is inserted verbatim, except inside plural/selectordinal branch text
+ * (a `select` nested in one included), where a bare `#` would become the count. There,
+ * when the preferred term contains `#`, the branch's literal text is re-quoted with ICU
+ * apostrophe quoting so the term reads back exactly as written. Values that do not parse
+ * as ICU are always edited verbatim.
*
* @example
* ```typescript
* applyPreferredTerm('Expenditure for {expenditure}', { discouraged: 'Expenditure', preferred: 'Investment' });
* // → 'Investment for {expenditure}'
+ *
+ * applyPreferredTerm('{n, plural, other {Old item}}', { discouraged: 'Old', preferred: 'C#' });
+ * // → "{n, plural, other {C'#' item}}"
* ```
*/
export function applyPreferredTerm(value: string, rule: PreferredTermRule): string {
@@ -483,10 +492,153 @@ export function applyPreferredTerm(value: string, rule: PreferredTermRule): stri
return value;
}
+ const pluralContent = rule.preferred.includes('#') ? pluralContentRanges(value) : [];
+ const edits: Array = [];
+ for (const content of pluralContent) {
+ const inside = finding.ranges.filter((range) => content.start <= range.start && range.end <= content.end);
+ if (inside.length > 0) {
+ edits.push({ ...content, text: requoteWithReplacements(value, content, inside, rule.preferred) });
+ }
+ }
+ for (const range of finding.ranges) {
+ if (!edits.some((edit) => edit.start <= range.start && range.end <= edit.end)) {
+ edits.push({ ...range, text: rule.preferred });
+ }
+ }
+
let result = value;
- for (let i = finding.ranges.length - 1; i >= 0; i--) {
- const { start, end } = finding.ranges[i];
- result = result.slice(0, start) + rule.preferred + result.slice(end);
+ for (const edit of edits.sort((a, b) => b.start - a.start)) {
+ result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
}
return result;
}
+
+/**
+ * Raw spans of the literal content tokens where `#` is the count placeholder: plural and
+ * selectordinal branch text, and the text of any select nested in one. Mirrors
+ * `@messageformat/parser` in its default, non-strict mode. Empty when `value` does not
+ * parse as ICU.
+ */
+function pluralContentRanges(value: string): PreferredTermRange[] {
+ const ranges: PreferredTermRange[] = [];
+ const walk = (tokens: readonly Token[], inPlural: boolean): void => {
+ for (const token of tokens) {
+ if (token.type === 'content') {
+ if (inPlural && token.ctx) {
+ ranges.push({ start: token.ctx.offset, end: token.ctx.offset + token.ctx.text.length });
+ }
+ } else if (token.type === 'plural' || token.type === 'selectordinal' || token.type === 'select') {
+ const branchInPlural = inPlural || token.type !== 'select';
+ for (const branch of token.cases) {
+ walk(branch.tokens, branchInPlural);
+ }
+ }
+ }
+ };
+
+ try {
+ walk(parse(maskTranslocoPlaceholders(value)), false);
+ } catch {
+ return [];
+ }
+ return ranges;
+}
+
+/**
+ * Rebuilds one plural-context content token: decodes its raw text to the literal a reader
+ * sees, swaps each matched span for `preferred`, and re-encodes the result.
+ */
+function requoteWithReplacements(
+ value: string,
+ content: PreferredTermRange,
+ matches: readonly PreferredTermRange[],
+ preferred: string,
+): string {
+ const { literal, rawToLiteral } = decodeIcuLiteral(value.slice(content.start, content.end));
+
+ let replaced = literal;
+ for (let i = matches.length - 1; i >= 0; i--) {
+ const start = rawToLiteral[matches[i].start - content.start];
+ const end = rawToLiteral[matches[i].end - content.start];
+ replaced = replaced.slice(0, start) + preferred + replaced.slice(end);
+ }
+ return encodePluralIcuLiteral(replaced);
+}
+
+/** `'{…}'`, `'}…'` or `'#…'` up to a closing apostrophe not followed by another; as in `@messageformat/parser`. */
+const ICU_QUOTED_PATTERN = /'[{}#](?:[^']|'')*'(?!')/uy;
+
+/**
+ * Decodes the raw text of one content token (no bare `{`, `}` or `#`) to its literal,
+ * with the literal offset at each raw offset. `''` reads as `'`; a quoted section reads
+ * as its contents.
+ */
+function decodeIcuLiteral(raw: string): { literal: string; rawToLiteral: number[] } {
+ let literal = '';
+ const rawToLiteral: number[] = [];
+ let i = 0;
+ while (i < raw.length) {
+ if (raw.startsWith("''", i)) {
+ rawToLiteral.push(literal.length, literal.length + 1);
+ literal += "'";
+ i += 2;
+ continue;
+ }
+
+ ICU_QUOTED_PATTERN.lastIndex = i;
+ const quoted = ICU_QUOTED_PATTERN.exec(raw);
+ if (quoted) {
+ const end = i + quoted[0].length - 1;
+ rawToLiteral.push(literal.length);
+ for (i++; i < end; i++) {
+ rawToLiteral.push(literal.length);
+ if (raw.startsWith("''", i)) {
+ rawToLiteral.push(literal.length + 1);
+ i++;
+ }
+ literal += raw[i];
+ }
+ rawToLiteral.push(literal.length);
+ i++;
+ continue;
+ }
+
+ rawToLiteral.push(literal.length);
+ literal += raw[i];
+ i++;
+ }
+ rawToLiteral.push(literal.length);
+ return { literal, rawToLiteral };
+}
+
+/**
+ * Encodes a literal as plural-context ICU text that parses back to exactly the literal.
+ * Each run of `{`, `}` and `#` is quoted, taking in any apostrophes that follow it so the
+ * closing quote is never followed by another. An apostrophe is doubled wherever a single
+ * one would start a quote, pair with its neighbour, or sit last before a following token.
+ */
+function encodePluralIcuLiteral(literal: string): string {
+ const isSyntax = (char: string | undefined): boolean => char === '{' || char === '}' || char === '#';
+
+ let out = '';
+ let i = 0;
+ while (i < literal.length) {
+ const char = literal[i];
+ if (isSyntax(char)) {
+ let quoted = '';
+ while (i < literal.length && (isSyntax(literal[i]) || literal[i] === "'")) {
+ quoted += literal[i] === "'" ? "''" : literal[i];
+ i++;
+ }
+ out += `'${quoted}'`;
+ } else if (char === "'") {
+ const next = literal[i + 1];
+ out += next === undefined || next === "'" || isSyntax(next) ? "''" : "'";
+ i++;
+ } else {
+ out += char;
+ i++;
+ }
+ }
+ return out;
+}
From 4725bcc892326e12e674d62d7e0bacdbe63f84c5 Mon Sep 17 00:00:00 2001
From: Simon Nodel
Date: Tue, 22 Sep 2026 20:52:05 -0700
Subject: [PATCH 18/22] fix(domain): mask tags with quoted ">" and non-ASCII
names in preferred-term checks
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The tag mask stopped at the first ">", so in
the tail of the attribute was treated as
visible text and could be flagged or rewritten. Tags named in a
non-Latin script, such as <étiquette>, were not masked at all. Tag names
may now start with any letter, "_" or ":", and quoted attribute values
are scanned through their closing quote. A "<" that does not open a
tag stays visible, and an unclosed tag or quote does not swallow the
rest of the value.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../src/lib/preferred-terminology.spec.ts | 24 +++++++++++++++++++
libs/domain/src/lib/preferred-terminology.ts | 10 ++++++--
2 files changed, 32 insertions(+), 2 deletions(-)
diff --git a/libs/domain/src/lib/preferred-terminology.spec.ts b/libs/domain/src/lib/preferred-terminology.spec.ts
index d59b917..6c4db23 100644
--- a/libs/domain/src/lib/preferred-terminology.spec.ts
+++ b/libs/domain/src/lib/preferred-terminology.spec.ts
@@ -269,6 +269,30 @@ describe('extractVisibleTextRanges', () => {
]);
});
+ it('masks a tag whose quoted attribute value contains ">"', () => {
+ expect(visibleText('Text')).toEqual(['Text']);
+ expect(visibleText("Text")).toEqual(['Text']);
+ });
+
+ it('masks tags whose name starts with a non-ASCII letter, "_" or ":"', () => {
+ expect(visibleText('<étiquette title="Expenditure">Textétiquette>')).toEqual(['Text']);
+ expect(visibleText('<_x a="Expenditure">A<:y b="Expenditure">B')).toEqual(['A', 'B']);
+ });
+
+ it('keeps a "<" that does not open a tag visible', () => {
+ expect(visibleText('a < b')).toEqual(['a < b']);
+ expect(visibleText('5 <3 and 2 > 1')).toEqual(['5 <3 and 2 > 1']);
+ });
+
+ it('does not let an unclosed tag or quote swallow the rest of the value', () => {
+ expect(visibleText(' {
+ expect(visibleText("It's Expenditure, isn't it")).toEqual(["It's ", 'Expenditure', ", isn't it"]);
+ });
+
it('falls back to masking braces and tags when ICU parsing fails', () => {
expect(visibleText('Broken {count, plural, one {x} and more text')).toEqual(['Broken ']);
expect(visibleText('Oops {first name} then text')).toEqual(['Oops ', ' then ', 'text']);
diff --git a/libs/domain/src/lib/preferred-terminology.ts b/libs/domain/src/lib/preferred-terminology.ts
index 0abb878..658b9ae 100644
--- a/libs/domain/src/lib/preferred-terminology.ts
+++ b/libs/domain/src/lib/preferred-terminology.ts
@@ -281,8 +281,14 @@ function buildPreferredTermRegex(term: string): RegExp {
return new RegExp(`(?|<\/?[A-Za-z][^<>]*>/g;
+/**
+ * HTML/XML tags (attributes included) and comments. A tag name starts with a letter in
+ * any script, `_` or `:`, so `a < b` and `5 <3` stay text. Quoted attribute values are
+ * scanned through their closing quote, so a `>` inside one does not end the tag. No part
+ * of a tag, quoted values included, may contain `<`, so an unclosed tag or quote never
+ * swallows the markup after it.
+ */
+const TAG_PATTERN = /|<\/?[\p{L}_:][^<>"']*(?:(?:"[^"<]*"|'[^'<]*')[^<>"']*)*>/gu;
/**
* Returns the spans of `value` a reader sees, as offsets into the raw value, sorted and
From dd6c339f15aef8b4899cdd920228c98fc28f4a6d Mon Sep 17 00:00:00 2001
From: Simon Nodel
Date: Tue, 22 Sep 2026 20:52:53 -0700
Subject: [PATCH 19/22] fix(cli): offer the collection's locales when import
prompts for a target locale
The interactive locale prompt listed the project-wide locales even when
the chosen collection defines its own. It now offers the collection's
locales, falling back to the project's.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
apps/cli/src/commands/import-cmd.spec.ts | 56 +++++++++++++++++++++++-
apps/cli/src/commands/import-cmd.ts | 9 ++--
2 files changed, 57 insertions(+), 8 deletions(-)
diff --git a/apps/cli/src/commands/import-cmd.spec.ts b/apps/cli/src/commands/import-cmd.spec.ts
index 3707782..a20580a 100644
--- a/apps/cli/src/commands/import-cmd.spec.ts
+++ b/apps/cli/src/commands/import-cmd.spec.ts
@@ -1,6 +1,7 @@
import * as fs from 'fs';
import * as path from 'path';
-import { beforeEach, describe, expect, it, vi } from 'vitest';
+import prompts from 'prompts';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { type ImportCommandOptions, importCommand } from './import-cmd';
const fsMocks = vi.hoisted(() => ({
@@ -81,7 +82,13 @@ vi.mock('../utils', () => ({
// Import the mocked functions
import { detectImportFormat, importFromJson, importFromXliff, loadPreferredTerminology } from '@simoncodes-ca/core';
-import { ConsoleFormatter, loadConfiguration, promptForCollection, resolveWritableCollection } from '../utils';
+import {
+ ConsoleFormatter,
+ isInteractiveTerminal,
+ loadConfiguration,
+ promptForCollection,
+ resolveWritableCollection,
+} from '../utils';
describe('import-cmd', () => {
const baseConfig = {
@@ -490,6 +497,51 @@ describe('import-cmd', () => {
});
});
+ 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' }];
diff --git a/apps/cli/src/commands/import-cmd.ts b/apps/cli/src/commands/import-cmd.ts
index 09486c0..a94b7c3 100644
--- a/apps/cli/src/commands/import-cmd.ts
+++ b/apps/cli/src/commands/import-cmd.ts
@@ -7,7 +7,6 @@ import {
type ImportStrategy,
importFromJson,
importFromXliff,
- type LingoTrackerConfig,
loadPreferredTerminology,
readEffectiveProtectedTerms,
} from '@simoncodes-ca/core';
@@ -54,10 +53,11 @@ export async function importCommand(options: ImportCommandOptions): Promise;
try {
- answers = await promptForMissing({ ...options, collection: collectionName }, config, baseLocale);
+ answers = await promptForMissing({ ...options, collection: collectionName }, locales, baseLocale);
} catch (error) {
if ((error as Error).message === 'Import cancelled') {
ConsoleFormatter.error(ErrorMessages.OPERATION_CANCELLED('Import'));
@@ -208,7 +208,7 @@ export async function importCommand(options: ImportCommandOptions): Promise {
const answers = { ...options };
@@ -279,9 +279,6 @@ async function promptForMissing(
answers.format = formatAnswer.format;
}
- // Get configured locales
- const configuredLocales = config.locales || [];
-
// Prompt for import strategy
if (!answers.strategy) {
const strategyAnswer = await prompts({
From 93e4cc10b0b40d6c0de54aff5f669ed7262e82bf Mon Sep 17 00:00:00 2001
From: Simon Nodel
Date: Tue, 22 Sep 2026 20:58:07 -0700
Subject: [PATCH 20/22] fix(tracker): escape the braces in the
invalid-character rule error
The stored value "Cannot contain { } < or >" is not valid ICU, so the
bundler warned on every locale and passed it through unconverted. Quote
the braces ICU-style; the bundle still renders the literal text.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../tracker/src/app/settings/settings.spec.ts | 49 +++++++++++++++
.../error/resource_entries.json | 20 +++----
.../error/tracker_meta.json | 60 +++++++++----------
3 files changed, 89 insertions(+), 40 deletions(-)
diff --git a/apps/tracker/src/app/settings/settings.spec.ts b/apps/tracker/src/app/settings/settings.spec.ts
index 8ceace4..6141150 100644
--- a/apps/tracker/src/app/settings/settings.spec.ts
+++ b/apps/tracker/src/app/settings/settings.spec.ts
@@ -1,9 +1,13 @@
import { signal } from '@angular/core';
import type { ComponentFixture } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { provideTranslocoMessageformat } from '@jsverse/transloco-messageformat';
import { createComponentFactory, type Spectator } from '@ngneat/spectator/vitest';
import type { LingoTrackerConfigDto, PreferredTermRuleErrorDto } from '@simoncodes-ca/data-transfer';
+import { icuToTransloco, validateICUSyntax } from '@simoncodes-ca/domain';
import { beforeEach, describe, expect, it, vi } from 'vitest';
+import ruleErrorEntries from '../../i18n/settings/preferredTerminology/error/resource_entries.json';
+import { TRACKER_TOKENS } from '../../i18n-types/tracker-resources';
import { getTranslocoTestingModule } from '../../testing/transloco-testing.module';
import { CollectionsStore } from '../collections/store/collections.store';
import { Settings } from './settings';
@@ -742,5 +746,50 @@ describe('Settings', () => {
expect(ruleInput(0, 'preferred').value).toBe('email');
expect(component.hasAnyChanges()).toBe(false);
});
+
+ describe('rule error messages', () => {
+ /** The app renders through the messageformat transpiler, so every stored value must be valid ICU. */
+ it('stores every rule error as valid ICU in every locale', () => {
+ for (const [key, entry] of Object.entries(ruleErrorEntries)) {
+ for (const [field, value] of Object.entries(entry)) {
+ if (field === 'comment' || field === 'tags') continue;
+ expect(validateICUSyntax(value as string), `${key} (${field}): ${value}`).toBe(true);
+ }
+ }
+ });
+
+ const createWithMessageformat = createComponentFactory({
+ component: Settings,
+ imports: [
+ NoopAnimationsModule,
+ getTranslocoTestingModule({
+ langs: {
+ en: {
+ // The bundled form, as `lingo-tracker bundle` writes it.
+ [TRACKER_TOKENS.SETTINGS.PREFERREDTERMINOLOGY.ERROR.INVALIDCHARACTER]: icuToTransloco(
+ ruleErrorEntries.invalidCharacter.source,
+ ),
+ },
+ },
+ }),
+ ],
+ providers: [provideTranslocoMessageformat()],
+ detectChanges: false,
+ });
+
+ it('renders the invalid-character error with its literal braces', () => {
+ spectator = createWithMessageformat({
+ providers: [{ provide: CollectionsStore, useValue: buildStore(terminologyConfig) }],
+ });
+ fixture = spectator.fixture;
+ component = spectator.component;
+ spectator.detectChanges();
+ spectator.flushEffects();
+
+ type(0, 'discouraged', 'E-{mail}');
+
+ expect(errorFor(0, 'discouraged')).toBe('Cannot contain { } < or >');
+ });
+ });
});
});
diff --git a/apps/tracker/src/i18n/settings/preferredTerminology/error/resource_entries.json b/apps/tracker/src/i18n/settings/preferredTerminology/error/resource_entries.json
index 5230a98..2fc6944 100644
--- a/apps/tracker/src/i18n/settings/preferredTerminology/error/resource_entries.json
+++ b/apps/tracker/src/i18n/settings/preferredTerminology/error/resource_entries.json
@@ -9,16 +9,6 @@
"ja": "Required",
"de": "Required"
},
- "invalidCharacter": {
- "source": "Cannot contain { } < or >",
- "comment": "Inline error when a term contains ICU or HTML syntax characters",
- "tags": ["settings"],
- "es": "Cannot contain { } < or >",
- "fr-ca": "Cannot contain { } < or >",
- "ru": "Cannot contain { } < or >",
- "ja": "Cannot contain { } < or >",
- "de": "Cannot contain { } < or >"
- },
"selfMapping": {
"source": "Must differ from the discouraged term",
"comment": "Inline error when the preferred term equals its own discouraged term (case-insensitive)",
@@ -78,5 +68,15 @@
"ru": "Must be text",
"ja": "Must be text",
"de": "Must be text"
+ },
+ "invalidCharacter": {
+ "source": "Cannot contain '{' '}' < or >",
+ "comment": "Inline error when a term contains ICU or HTML syntax characters",
+ "tags": ["settings"],
+ "es": "Cannot contain '{' '}' < or >",
+ "fr-ca": "Cannot contain '{' '}' < or >",
+ "ru": "Cannot contain '{' '}' < or >",
+ "ja": "Cannot contain '{' '}' < or >",
+ "de": "Cannot contain '{' '}' < or >"
}
}
diff --git a/apps/tracker/src/i18n/settings/preferredTerminology/error/tracker_meta.json b/apps/tracker/src/i18n/settings/preferredTerminology/error/tracker_meta.json
index 0b0a579..60fb2e3 100644
--- a/apps/tracker/src/i18n/settings/preferredTerminology/error/tracker_meta.json
+++ b/apps/tracker/src/i18n/settings/preferredTerminology/error/tracker_meta.json
@@ -29,36 +29,6 @@
"status": "new"
}
},
- "invalidCharacter": {
- "en": {
- "checksum": "eac0c8a97f4aeaab52231c20d5ca833c"
- },
- "es": {
- "checksum": "eac0c8a97f4aeaab52231c20d5ca833c",
- "baseChecksum": "eac0c8a97f4aeaab52231c20d5ca833c",
- "status": "new"
- },
- "fr-ca": {
- "checksum": "eac0c8a97f4aeaab52231c20d5ca833c",
- "baseChecksum": "eac0c8a97f4aeaab52231c20d5ca833c",
- "status": "new"
- },
- "ru": {
- "checksum": "eac0c8a97f4aeaab52231c20d5ca833c",
- "baseChecksum": "eac0c8a97f4aeaab52231c20d5ca833c",
- "status": "new"
- },
- "ja": {
- "checksum": "eac0c8a97f4aeaab52231c20d5ca833c",
- "baseChecksum": "eac0c8a97f4aeaab52231c20d5ca833c",
- "status": "new"
- },
- "de": {
- "checksum": "eac0c8a97f4aeaab52231c20d5ca833c",
- "baseChecksum": "eac0c8a97f4aeaab52231c20d5ca833c",
- "status": "new"
- }
- },
"selfMapping": {
"en": {
"checksum": "9de9f90b231bb1669f774cce8c22f841"
@@ -238,5 +208,35 @@
"baseChecksum": "598cba41c17504cef57c0a1224d47c94",
"status": "new"
}
+ },
+ "invalidCharacter": {
+ "en": {
+ "checksum": "8e1b8cc8fe4fd447eba9e2886eb42f1c"
+ },
+ "es": {
+ "checksum": "8e1b8cc8fe4fd447eba9e2886eb42f1c",
+ "baseChecksum": "8e1b8cc8fe4fd447eba9e2886eb42f1c",
+ "status": "new"
+ },
+ "fr-ca": {
+ "checksum": "8e1b8cc8fe4fd447eba9e2886eb42f1c",
+ "baseChecksum": "8e1b8cc8fe4fd447eba9e2886eb42f1c",
+ "status": "new"
+ },
+ "ru": {
+ "checksum": "8e1b8cc8fe4fd447eba9e2886eb42f1c",
+ "baseChecksum": "8e1b8cc8fe4fd447eba9e2886eb42f1c",
+ "status": "new"
+ },
+ "ja": {
+ "checksum": "8e1b8cc8fe4fd447eba9e2886eb42f1c",
+ "baseChecksum": "8e1b8cc8fe4fd447eba9e2886eb42f1c",
+ "status": "new"
+ },
+ "de": {
+ "checksum": "8e1b8cc8fe4fd447eba9e2886eb42f1c",
+ "baseChecksum": "8e1b8cc8fe4fd447eba9e2886eb42f1c",
+ "status": "new"
+ }
}
}
From 865e7daf5f884387b5e1f29e87250b4af33f66b1 Mon Sep 17 00:00:00 2001
From: Simon Nodel
Date: Tue, 22 Sep 2026 21:00:26 -0700
Subject: [PATCH 21/22] fix(tracker): lock settings editing until config loads
and while a save is in flight
Both config arrivals reseed the protected-terms and preferred-terminology
lists, so a rule or term entered before the first config, or after
clicking Save, was silently wiped. Disable every edit control, Revert and
Save in those windows.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
apps/tracker/src/app/settings/settings.html | 14 +-
.../tracker/src/app/settings/settings.spec.ts | 147 +++++++++++++++++-
apps/tracker/src/app/settings/settings.ts | 10 +-
3 files changed, 166 insertions(+), 5 deletions(-)
diff --git a/apps/tracker/src/app/settings/settings.html b/apps/tracker/src/app/settings/settings.html
index 5bd4715..2119949 100644
--- a/apps/tracker/src/app/settings/settings.html
+++ b/apps/tracker/src/app/settings/settings.html
@@ -17,7 +17,7 @@
-
+ add
{{ TOKENS.SETTINGS.PREFERREDTERMINOLOGY.ADDBUTTON | transloco }}
diff --git a/apps/tracker/src/app/settings/settings.spec.ts b/apps/tracker/src/app/settings/settings.spec.ts
index 6141150..59fa749 100644
--- a/apps/tracker/src/app/settings/settings.spec.ts
+++ b/apps/tracker/src/app/settings/settings.spec.ts
@@ -672,9 +672,10 @@ describe('Settings', () => {
it('maps server errors onto the submitted rows and clears one when its field is edited', () => {
const configRuleErrors = signal([]);
+ const error = signal(null);
renderStore({
config: signal(terminologyConfig),
- error: signal(null),
+ error,
isLoading: signal(false),
configRuleErrors,
updateGlobalConfig: updateGlobalConfigMock,
@@ -682,6 +683,7 @@ describe('Settings', () => {
type(1, 'reason', 'Changed.');
component.save();
+ error.set('server says no');
configRuleErrors.set([{ index: 1, field: 'preferred', code: 'self-mapping', message: 'server says no' }]);
spectator.flushEffects();
spectator.detectChanges();
@@ -747,6 +749,149 @@ describe('Settings', () => {
expect(component.hasAnyChanges()).toBe(false);
});
+ describe('editing lock', () => {
+ const addRuleButton = (): HTMLButtonElement => {
+ const button = host().querySelector('button.rules-add');
+ expect(button).not.toBeNull();
+ return button as HTMLButtonElement;
+ };
+ const protectedAddInput = (): HTMLInputElement => {
+ const input = host().querySelector('.terms-add input');
+ expect(input).not.toBeNull();
+ return input as HTMLInputElement;
+ };
+ /** Every control that changes either list: rule inputs, rule and term buttons, the term add field. */
+ const editControls = () =>
+ Array.from(
+ host().querySelectorAll(
+ 'input.rule-input, button.rule-remove, button.rules-add, .terms-add input, .term-action, .term-undo',
+ ),
+ );
+ const buildPendingStore = () => ({
+ config: signal(terminologyConfig),
+ error: signal(null),
+ isLoading: signal(false),
+ configRuleErrors: signal([]),
+ updateGlobalConfig: updateGlobalConfigMock,
+ });
+ const settle = () => {
+ spectator.detectChanges();
+ spectator.flushEffects();
+ spectator.detectChanges();
+ };
+
+ it('keeps both lists read-only until the config has loaded', () => {
+ const store = { ...buildPendingStore(), config: signal(null) };
+ renderStore(store);
+
+ expect(component.editingLocked()).toBe(true);
+ expect(addRuleButton().disabled).toBe(true);
+ expect(protectedAddInput().disabled).toBe(true);
+
+ store.config.set(terminologyConfig);
+ settle();
+
+ expect(component.editingLocked()).toBe(false);
+ expect(addRuleButton().disabled).toBe(false);
+ expect(editControls().every((control) => !control.disabled)).toBe(true);
+ });
+
+ it('does not let a rule be added before the config seeds the list', () => {
+ const store = { ...buildPendingStore(), config: signal(null) };
+ renderStore(store);
+
+ clickAdd();
+
+ expect(ruleRows()).toHaveLength(0);
+ expect(component.terminology.isEmpty()).toBe(true);
+
+ store.config.set(terminologyConfig);
+ settle();
+
+ expect(ruleRows()).toHaveLength(2);
+ expect(component.terminology.hasChanges()).toBe(false);
+ });
+
+ it('locks every edit control while a save is in flight and unlocks once the reloaded config arrives', () => {
+ const store = buildPendingStore();
+ renderStore(store);
+ type(0, 'preferred', 'Email');
+
+ saveButton().click();
+ spectator.detectChanges();
+
+ expect(updateGlobalConfigMock).toHaveBeenCalled();
+ expect(editControls().length).toBeGreaterThan(0);
+ expect(editControls().every((control) => control.disabled)).toBe(true);
+ expect(saveButton().disabled).toBe(true);
+
+ // A rule cannot be started that the post-save reseed would wipe.
+ clickAdd();
+ expect(ruleRows()).toHaveLength(2);
+
+ // The store clears its loading flag once the write lands, before the refetch: still locked.
+ store.isLoading.set(true);
+ spectator.detectChanges();
+ store.isLoading.set(false);
+ spectator.detectChanges();
+ expect(addRuleButton().disabled).toBe(true);
+
+ store.config.set({
+ ...terminologyConfig,
+ preferredTerminology: [
+ { discouraged: 'E-mail', preferred: 'Email' },
+ { discouraged: 'Expenditure', preferred: 'Investment', reason: 'Current planning term.' },
+ ],
+ });
+ settle();
+
+ expect(editControls().every((control) => !control.disabled)).toBe(true);
+ expect(ruleInput(0, 'preferred').value).toBe('Email');
+ });
+
+ it('unlocks after a failed save and keeps the unsaved edits', () => {
+ const store = buildPendingStore();
+ renderStore(store);
+ type(0, 'preferred', 'Email');
+
+ saveButton().click();
+ spectator.detectChanges();
+ expect(addRuleButton().disabled).toBe(true);
+
+ store.error.set('update failed');
+ settle();
+
+ expect(editControls().every((control) => !control.disabled)).toBe(true);
+ expect(ruleInput(0, 'preferred').value).toBe('Email');
+ expect(component.terminology.changeCount()).toBe(1);
+ });
+
+ it('keeps an edit made once a save has completed when a later config refetch arrives', () => {
+ const store = buildPendingStore();
+ renderStore(store);
+ type(0, 'preferred', 'Email');
+ saveButton().click();
+ const saved = {
+ ...terminologyConfig,
+ preferredTerminology: [
+ { discouraged: 'E-mail', preferred: 'Email' },
+ { discouraged: 'Expenditure', preferred: 'Investment', reason: 'Current planning term.' },
+ ],
+ };
+ store.config.set(saved);
+ settle();
+
+ clickAdd();
+ type(2, 'discouraged', 'Cost');
+ type(2, 'preferred', 'Price');
+ store.config.set({ ...saved });
+ settle();
+
+ expect(ruleRows()).toHaveLength(3);
+ expect(component.terminology.rulesToSave()[2]).toEqual({ discouraged: 'Cost', preferred: 'Price' });
+ });
+ });
+
describe('rule error messages', () => {
/** The app renders through the messageformat transpiler, so every stored value must be valid ICU. */
it('stores every rule error as valid ICU in every locale', () => {
diff --git a/apps/tracker/src/app/settings/settings.ts b/apps/tracker/src/app/settings/settings.ts
index ae90cef..6920a9b 100644
--- a/apps/tracker/src/app/settings/settings.ts
+++ b/apps/tracker/src/app/settings/settings.ts
@@ -115,6 +115,13 @@ export class Settings {
readonly #seeded = signal(false);
/** Set while a save is in flight so the next config arrival is treated as the new baseline. */
readonly #awaitingSave = signal(false);
+ /**
+ * Locks both editors until the first config seeds them, and while a save is in flight: each
+ * of those config arrivals reseeds both lists, so an edit made in either window would be lost.
+ * The save latch marks the save, not `store.isLoading()`, which clears once the write lands —
+ * before the refetch that reseeds arrives.
+ */
+ readonly editingLocked = computed(() => !this.#seeded() || this.#awaitingSave());
#nextId = 0;
/** Row to reveal once it has rendered, so an added term is never added off-screen. */
readonly #scrollToId = signal(null);
@@ -156,7 +163,8 @@ export class Settings {
readonly showSaveBar = computed(() => !this.isEmpty() || !this.terminology.isEmpty() || this.hasAnyChanges());
/** Save stays enabled while terminology errors are still hidden, so clicking it can reveal them. */
readonly canSave = computed(
- () => this.hasAnyChanges() && !this.store.isLoading() && !this.terminology.hasVisibleErrors(),
+ () =>
+ this.hasAnyChanges() && !this.store.isLoading() && !this.editingLocked() && !this.terminology.hasVisibleErrors(),
);
readonly showFilter = computed(() => this.entries().length > FILTER_THRESHOLD);
readonly isFiltering = computed(() => this.filter().trim().length > 0);
From 629d7f98085a39c4bd96e5b7bd3efc7d494019be Mon Sep 17 00:00:00 2001
From: Simon Nodel
Date: Tue, 22 Sep 2026 21:10:11 -0700
Subject: [PATCH 22/22] fix(core): report a non-string preferredTerminologyFile
as a load error
.lingo-tracker.json is hand-edited and not schema-validated. A pointer
such as 42, true, {} or [] reached path.isAbsolute() and threw a raw
TypeError out of loadPreferredTerminology, which is documented as never
throwing, crashing GET /config, validate, add/edit-resource, import and
the preferred-terminology command.
loadPreferredTerminology now returns the problem in-band (no rules,
default path, a clear error), so readers warn or fail as they already do
for a broken file, and the CLI refuses --add/--remove. The resolver
throws a descriptive Error instead of the TypeError, so PUT /config
answers 400 with a readable message. A null pointer still reads as
unset, and no longer triggers the missing-file warning.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../src/app/config/config.controller.spec.ts | 13 ++++++
.../config/preferred-terminology-file.spec.ts | 40 +++++++++++++++++++
.../lib/config/preferred-terminology-file.ts | 30 ++++++++++++--
3 files changed, 79 insertions(+), 4 deletions(-)
diff --git a/apps/api/src/app/config/config.controller.spec.ts b/apps/api/src/app/config/config.controller.spec.ts
index 8ec6303..4ec35fa 100644
--- a/apps/api/src/app/config/config.controller.spec.ts
+++ b/apps/api/src/app/config/config.controller.spec.ts
@@ -289,6 +289,19 @@ describe('ConfigController', () => {
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', () => {
diff --git a/libs/core/src/lib/config/preferred-terminology-file.spec.ts b/libs/core/src/lib/config/preferred-terminology-file.spec.ts
index 113f207..8551b61 100644
--- a/libs/core/src/lib/config/preferred-terminology-file.spec.ts
+++ b/libs/core/src/lib/config/preferred-terminology-file.spec.ts
@@ -57,6 +57,12 @@ describe('preferred-terminology-file', () => {
resolvePreferredTerminologyFilePath(baseConfig({ preferredTerminologyFile: '/etc/terms.json' }), cwd),
).toBe('/etc/terms.json');
});
+
+ it('throws a descriptive error for a non-string pointer', () => {
+ expect(() =>
+ resolvePreferredTerminologyFilePath(baseConfig({ preferredTerminologyFile: 42 as never }), cwd),
+ ).toThrow('"preferredTerminologyFile" in .lingo-tracker.json must be a string path (got number)');
+ });
});
describe('loadPreferredTerminology', () => {
@@ -125,6 +131,40 @@ describe('preferred-terminology-file', () => {
expect(result.error).toContain('ENOTDIR');
});
+ it.each([
+ ['a number', 42, 'number'],
+ ['a boolean', true, 'boolean'],
+ ['an object', {}, 'object'],
+ ['an array', [], 'array'],
+ ])('reports %s pointer as an error with no rules, without throwing', (_label, pointer, type) => {
+ const config = baseConfig({ preferredTerminologyFile: pointer as never });
+
+ const result = loadPreferredTerminology(config, cwd);
+
+ expect(result.rules).toEqual([]);
+ expect(result.warning).toBeUndefined();
+ expect(result.filePath).toBe(resolve(cwd, DEFAULT_PREFERRED_TERMINOLOGY_FILENAME));
+ expect(result.error).toBe(
+ `"preferredTerminologyFile" in .lingo-tracker.json must be a string path (got ${type})`,
+ );
+ });
+
+ it('treats a null pointer like an unset one', () => {
+ const config = baseConfig({ preferredTerminologyFile: null as never });
+
+ const result = loadPreferredTerminology(config, cwd);
+
+ expect(result).toEqual({ rules: [], filePath: resolve(cwd, DEFAULT_PREFERRED_TERMINOLOGY_FILENAME) });
+ });
+
+ it('reports an empty pointer, which resolves to the config directory, as an unreadable file', () => {
+ const result = loadPreferredTerminology(baseConfig({ preferredTerminologyFile: '' }), cwd);
+
+ expect(result.rules).toEqual([]);
+ expect(result.error).toContain('cannot be read');
+ expect(result.error).toContain('EISDIR');
+ });
+
it('reports a directory at the file path as an unreadable file, not as invalid JSON', () => {
mkdirSync(join(cwd, DEFAULT_PREFERRED_TERMINOLOGY_FILENAME));
diff --git a/libs/core/src/lib/config/preferred-terminology-file.ts b/libs/core/src/lib/config/preferred-terminology-file.ts
index 65346b3..a0dca34 100644
--- a/libs/core/src/lib/config/preferred-terminology-file.ts
+++ b/libs/core/src/lib/config/preferred-terminology-file.ts
@@ -68,11 +68,18 @@ export function clearPreferredTerminologyCache(): void {
* Absolute path of the preferred-terminology file: the config's
* `preferredTerminologyFile` pointer resolved against `cwd` (the directory holding the
* config file), falling back to the default filename. Absolute pointers are used as-is.
+ *
+ * Throws a descriptive `Error` when the pointer is neither a string nor unset (`null`
+ * counts as unset): `.lingo-tracker.json` is hand-edited and not schema-validated.
*/
export function resolvePreferredTerminologyFilePath(
config: Pick,
cwd: string = process.cwd(),
): string {
+ const pointerError = invalidPointerError(config);
+ if (pointerError) {
+ throw new Error(pointerError);
+ }
const pointer = config.preferredTerminologyFile ?? DEFAULT_PREFERRED_TERMINOLOGY_FILENAME;
return isAbsolute(pointer) ? pointer : resolve(cwd, pointer);
}
@@ -84,14 +91,19 @@ export function resolvePreferredTerminologyFilePath(
* Never throws. A missing file at the default path reads as an empty list — the normal
* state before any rule has been added. A missing file at an explicit pointer also
* reads as empty but sets `warning`, since a pointer at nothing is usually a typo.
- * Malformed JSON, a non-array payload, or any rule failing validation sets `error` and
- * returns no rules: terminology checks are advisory, so callers warn and skip them
- * rather than abort, except `validate`, which reports a broken file as a failure.
+ * A non-string pointer, malformed JSON, a non-array payload, or any rule failing
+ * validation sets `error` and returns no rules: terminology checks are advisory, so
+ * callers warn and skip them rather than abort, except `validate`, which reports a
+ * broken file as a failure.
*/
export function loadPreferredTerminology(
config: Pick,
cwd: string = process.cwd(),
): LoadPreferredTerminologyResult {
+ const pointerError = invalidPointerError(config);
+ if (pointerError) {
+ return { rules: [], filePath: resolve(cwd, DEFAULT_PREFERRED_TERMINOLOGY_FILENAME), error: pointerError };
+ }
const filePath = resolvePreferredTerminologyFilePath(config, cwd);
let stamp: FileStamp | undefined;
@@ -111,7 +123,7 @@ export function loadPreferredTerminology(
}
if (!stamp) {
- if (config.preferredTerminologyFile !== undefined) {
+ if (config.preferredTerminologyFile != null) {
return {
rules: [],
filePath,
@@ -199,6 +211,16 @@ export function writePreferredTerminology(filePath: string, rules: readonly Pref
}
}
+/** Error message for a `preferredTerminologyFile` that is set but not a string; `undefined` when usable. */
+function invalidPointerError(config: Pick): string | undefined {
+ const pointer: unknown = config.preferredTerminologyFile;
+ if (pointer === undefined || pointer === null || typeof pointer === 'string') {
+ return undefined;
+ }
+ const type = Array.isArray(pointer) ? 'array' : typeof pointer;
+ return `"preferredTerminologyFile" in .lingo-tracker.json must be a string path (got ${type})`;
+}
+
/** One `row N field: message` entry per error, rows 1-based, joined into a single line. */
function formatRuleErrors(errors: readonly PreferredTermRuleError[]): string {
return errors.map((error) => `row ${error.index + 1} ${error.field}: ${error.message}`).join('; ');