Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 48 additions & 9 deletions src/backend/services/locale_service.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import Story from '../models/story.js';
import Ui from '../models/ui.js';
import { parseLanguageSpecification } from '../../shared/language_helpers.js';
import type { CmsConfig, LocaleItem, LocaleIndexResponse } from '../../types.js';
import { FlagState } from '../../types.js';
import type { CmsConfig, LocaleIndexResponse } from '../../types.js';

const APP_UI_TRANSLATION_THRESHOLD = 0.8;

export class LocaleService {
public constructor(protected readonly config: CmsConfig) {}

public get sourceLocale(): string {
return this.config.languages[0].locale;
}

public async localeIndex(): Promise<LocaleIndexResponse> {
const locales = this.config.languages.map((language) => language.locale);
const languages = this.config.languages.map(parseLanguageSpecification);

const stories = await Story.query()
.select('id', 'slug', 'order')
Expand All @@ -30,15 +39,45 @@ export class LocaleService {
.map((locale) => ({ locale, stories: byLocale.get(locale) ?? [] }))
.filter((item) => item.stories.length > 0);

const languagesByLocale = new Map(
this.config.languages.map((language) => [language.locale, language]),
);
const app = await this.appLocales(locales);

return {
languages,
content,
app,
media: [this.sourceLocale],
};
}

private async appLocales(locales: string[]): Promise<string[]> {
const sourceLocale = this.sourceLocale;
const rows = await Ui.query().whereIn('locale', locales);
const totalUiCount = rows.filter((row) => row.locale === sourceLocale).length;

const app = [sourceLocale];

const app: LocaleItem[] = content.map(({ locale }) => {
const spec = languagesByLocale.get(locale)!;
return parseLanguageSpecification(spec);
});
if (totalUiCount === 0) {
return app;
}

for (const locale of locales) {
if (locale === sourceLocale) {
continue;
}

const translatedCount = rows.filter(
(row) =>
row.locale === locale &&
row.microCopy &&
row.microCopy.trim() !== '' &&
row.flag !== FlagState.PREFILLED,
).length;

if (translatedCount / totalUiCount > APP_UI_TRANSLATION_THRESHOLD) {
app.push(locale);
}
}

return { content, app };
return app;
}
}
176 changes: 145 additions & 31 deletions src/backend/stubs/tests/unit/locale_service.stub
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
StoryFactory,
StoryLocalisation,
StoryLocalisationFactory,
Ui,
User,
} from '@story-cms/kit';
import { testCmsConfig, createCustomCmsConfig } from '#tests/helpers/cms_mock';

Expand Down Expand Up @@ -35,8 +37,66 @@ async function publishInLocale(storyId: number, locale: string) {
}).create();
}

async function seedUiStrings(
sourceKeys: string[],
translations: Record<string, Record<string, string | { text: string; flag?: string | null }>>,
) {
await User.create({
id: 1,
name: 'Test User',
email: 'test@test.com',
password: 'password',
language: '*',
});

const rows = sourceKeys.flatMap((key) => {
const sourceRows = [
{
locale: sourceLocale,
key,
microCopy: {{ '`Source ${key}`' }},
flag: null,
updatedBy: 1,
},
];

const localeRows = Object.entries(translations).flatMap(([locale, items]) => {
const item = items[key];
if (!item) {
return [];
}

if (typeof item === 'string') {
return [
{
locale,
key,
microCopy: item,
flag: null,
updatedBy: 1,
},
];
}

return [
{
locale,
key,
microCopy: item.text,
flag: item.flag ?? null,
updatedBy: 1,
},
];
});

return [...sourceRows, ...localeRows];
});

await Ui.createMany(rows);
}

test.group('LocaleService', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction());
group.each.setup(() => testUtils.db().wrapInGlobalTransaction());

test('returns slugs grouped by locale', async ({ assert }) => {
const classic = await StoryFactory.with('localisations')
Expand All @@ -60,10 +120,7 @@ test.group('LocaleService', (group) => {
assert.isDefined(translationEntry);
assert.deepEqual(sourceEntry!.stories, ['classic', 'youth']);
assert.deepEqual(translationEntry!.stories, ['classic']);
assert.deepEqual(
app.map((item) => item.locale),
content.map((item) => item.locale),
);
assert.deepEqual(app, [sourceLocale]);
});

test('respects publish state per locale', async ({ assert }) => {
Expand All @@ -89,7 +146,7 @@ test.group('LocaleService', (group) => {
assert.isDefined(sourceEntry);
assert.deepEqual(sourceEntry!.stories, ['express']);
assert.isUndefined(translationEntry);
assert.deepEqual(app.map((item) => item.locale), [sourceLocale]);
assert.deepEqual(app, [sourceLocale]);
});

test('omits locales with no published stories', async ({ assert }) => {
Expand All @@ -105,8 +162,7 @@ test.group('LocaleService', (group) => {
assert.isTrue(content.every((item) => item.stories.length > 0));
assert.isUndefined(content.find((item) => item.locale === translationLocale));
assert.isUndefined(content.find((item) => item.locale === thirdLocale));
assert.isUndefined(app.find((item) => item.locale === translationLocale));
assert.isUndefined(app.find((item) => item.locale === thirdLocale));
assert.deepEqual(app, [sourceLocale]);
});

test('preserves story order within each locale', async ({ assert }) => {
Expand All @@ -126,7 +182,7 @@ test.group('LocaleService', (group) => {
const sourceEntry = content.find((item) => item.locale === sourceLocale);
assert.isDefined(sourceEntry);
assert.deepEqual(sourceEntry!.stories, ['first', 'second']);
assert.deepEqual(app.map((item) => item.locale), [sourceLocale]);
assert.deepEqual(app, [sourceLocale]);
});

test('uses configured locale order in response', async ({ assert }) => {
Expand All @@ -138,19 +194,19 @@ test.group('LocaleService', (group) => {
await publishInLocale(story.id, translationLocale);

const service = new LocaleService(testCmsConfig);
const { content, app } = await service.localeIndex();
const { content, languages } = await service.localeIndex();

assert.deepEqual(
content.map((item) => item.locale),
[sourceLocale, translationLocale],
);
assert.deepEqual(
app.map((item) => item.locale),
[sourceLocale, translationLocale],
languages.map((item) => item.locale),
[sourceLocale, translationLocale, thirdLocale],
);
});

test('returns parsed name and nativeName in app metadata', async ({ assert }) => {
test('returns all configured languages in languages metadata', async ({ assert }) => {
const config = createCustomCmsConfig({
languages: [
{
Expand All @@ -166,23 +222,16 @@ test.group('LocaleService', (group) => {
],
});

const story = await StoryFactory.with('localisations')
.merge({ slug: 'localized', order: 1 })
.create();

await publishInLocale(story.id, 'en');
await publishInLocale(story.id, 'es');

const service = new LocaleService(config);
const { app } = await service.localeIndex();
const { languages } = await service.localeIndex();

assert.deepEqual(app, [
assert.deepEqual(languages, [
{ locale: 'en', name: 'English', nativeName: 'English', languageDirection: 'ltr' },
{ locale: 'es', name: 'Spanish', nativeName: 'Español', languageDirection: 'ltr' },
]);
});

test('returns languageDirection in app metadata', async ({ assert }) => {
test('returns languageDirection in languages metadata', async ({ assert }) => {
const config = createCustomCmsConfig({
languages: [
{
Expand All @@ -193,16 +242,10 @@ test.group('LocaleService', (group) => {
],
});

const story = await StoryFactory.with('localisations')
.merge({ slug: 'rtl-story', order: 1 })
.create();

await publishInLocale(story.id, 'ar');

const service = new LocaleService(config);
const { app } = await service.localeIndex();
const { languages } = await service.localeIndex();

assert.deepEqual(app, [
assert.deepEqual(languages, [
{
locale: 'ar',
name: 'Arabic',
Expand All @@ -211,4 +254,75 @@ test.group('LocaleService', (group) => {
},
]);
});

test('always includes source locale in app', async ({ assert }) => {
const service = new LocaleService(testCmsConfig);
const { app } = await service.localeIndex();

assert.deepEqual(app, [sourceLocale]);
});

test('includes locales with more than 80% non-AI UI translation in app', async ({
assert,
}) => {
await seedUiStrings(['k1', 'k2', 'k3', 'k4', 'k5'], {
[translationLocale]: {
k1: 'Uno',
k2: 'Dos',
k3: 'Tres',
k4: 'Cuatro',
k5: 'Cinco',
},
[thirdLocale]: {
k1: 'One',
k2: 'Two',
},
});

const service = new LocaleService(testCmsConfig);
const { app } = await service.localeIndex();

assert.deepEqual(app, [sourceLocale, translationLocale]);
});

test('excludes AI-prefilled translations from app progress', async ({ assert }) => {
await seedUiStrings(['k1', 'k2', 'k3', 'k4', 'k5'], {
[translationLocale]: {
k1: 'Uno',
k2: 'Dos',
k3: 'Tres',
k4: 'Cuatro',
k5: { text: 'Cinco', flag: 'prefilled' },
},
});

const service = new LocaleService(testCmsConfig);
const { app } = await service.localeIndex();

assert.deepEqual(app, [sourceLocale]);
});

test('counts recheck translations toward app progress', async ({ assert }) => {
await seedUiStrings(['k1', 'k2', 'k3', 'k4', 'k5'], {
[translationLocale]: {
k1: 'Uno',
k2: 'Dos',
k3: 'Tres',
k4: 'Cuatro',
k5: { text: 'Cinco', flag: 'recheck' },
},
});

const service = new LocaleService(testCmsConfig);
const { app } = await service.localeIndex();

assert.deepEqual(app, [sourceLocale, translationLocale]);
});

test('returns source locale in media', async ({ assert }) => {
const service = new LocaleService(testCmsConfig);
const { media } = await service.localeIndex();

assert.deepEqual(media, [sourceLocale]);
});
});
4 changes: 3 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,8 +422,10 @@ export interface LocaleItem extends Pick<
}

export interface LocaleIndexResponse {
languages: LocaleItem[];
content: LocaleContentItem[];
app: LocaleItem[];
app: string[];
media: string[];
}

export interface DraftEditProps {
Expand Down
Loading