From 9ef7b4cf4ba381452003fa257b66b382bdce6a5a Mon Sep 17 00:00:00 2001 From: Timothy Koech Date: Wed, 22 Jul 2026 14:46:08 +0300 Subject: [PATCH 1/8] feat: add LocaleService, controller, routes and related types for managing localized stories --- src/backend/configure.ts | 3 + src/backend/index.ts | 1 + src/backend/services/locale_service.ts | 34 +++++ .../stubs/controllers/locale_controller.stub | 13 ++ src/backend/stubs/routes/api.stub | 3 + src/backend/stubs/routes/locale.stub | 9 ++ src/backend/stubs/tests/rest.stub | 4 + .../stubs/tests/unit/locale_service.stub | 140 ++++++++++++++++++ src/types.ts | 9 ++ 9 files changed, 216 insertions(+) create mode 100644 src/backend/services/locale_service.ts create mode 100644 src/backend/stubs/controllers/locale_controller.stub create mode 100644 src/backend/stubs/routes/locale.stub create mode 100644 src/backend/stubs/tests/unit/locale_service.stub diff --git a/src/backend/configure.ts b/src/backend/configure.ts index 87a1acc9..190d7aa5 100644 --- a/src/backend/configure.ts +++ b/src/backend/configure.ts @@ -119,6 +119,7 @@ export async function configure(command: Configure) { await codemods.makeUsingStub(stubsRoot, 'controllers/stories_controller.stub', {}); await codemods.makeUsingStub(stubsRoot, 'controllers/users_controller.stub', {}); await codemods.makeUsingStub(stubsRoot, 'controllers/settings_controller.stub', {}); + await codemods.makeUsingStub(stubsRoot, 'controllers/locale_controller.stub', {}); await codemods.makeUsingStub(stubsRoot, 'inertia/middleware.stub', {}); @@ -136,6 +137,7 @@ export async function configure(command: Configure) { await codemods.makeUsingStub(stubsRoot, 'routes/audience.stub', {}); await codemods.makeUsingStub(stubsRoot, 'routes/preferences.stub', {}); await codemods.makeUsingStub(stubsRoot, 'routes/streams.stub', {}); + await codemods.makeUsingStub(stubsRoot, 'routes/locale.stub', {}); await codemods.makeUsingStub(stubsRoot, 'mail/forget_password.stub', {}); await codemods.makeUsingStub(stubsRoot, 'mail/create_account.stub', {}); @@ -169,6 +171,7 @@ export async function configure(command: Configure) { await codemods.makeUsingStub(stubsRoot, 'tests/unit/user_service.stub', {}); await codemods.makeUsingStub(stubsRoot, 'tests/unit/progress_service.stub', {}); await codemods.makeUsingStub(stubsRoot, 'tests/unit/language_service.stub', {}); + await codemods.makeUsingStub(stubsRoot, 'tests/unit/locale_service.stub', {}); await codemods.makeUsingStub(stubsRoot, 'tests/unit/model.stub', {}); await codemods.makeUsingStub(stubsRoot, 'tests/helpers/cms_mock.stub', {}); await codemods.makeUsingStub(stubsRoot, 'tests/helpers/story_test_helper.stub', {}); diff --git a/src/backend/index.ts b/src/backend/index.ts index 675c191c..0ca3236b 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -42,6 +42,7 @@ export * from './services/index_service.js'; export * from './services/page_service.js'; export * from './services/progress_service.js'; export * from './services/language_service.js'; +export * from './services/locale_service.js'; export * from './services/ui_service.js'; export * from './services/user_service.js'; export * from './services/stream_service.js'; diff --git a/src/backend/services/locale_service.ts b/src/backend/services/locale_service.ts new file mode 100644 index 00000000..d05a0459 --- /dev/null +++ b/src/backend/services/locale_service.ts @@ -0,0 +1,34 @@ +import Story from '../models/story.js'; +import type { CmsConfig, LocaleStoriesResponse } from '../../types.js'; + +export class LocaleService { + public constructor(protected readonly config: CmsConfig) {} + + public async availableStories(): Promise { + const locales = this.config.languages.map((language) => language.locale); + + const stories = await Story.query() + .select('id', 'slug', 'order') + .preload('localisations', (query) => { + query.where('isPublished', true).whereIn('locale', locales); + }) + .whereHas('localisations', (query) => { + query.where('isPublished', true).whereIn('locale', locales); + }) + .orderBy('order', 'asc'); + + const byLocale = new Map(locales.map((locale) => [locale, [] as string[]])); + + for (const story of stories) { + for (const localisation of story.localisations) { + byLocale.get(localisation.locale)?.push(story.slug); + } + } + + return { + content: locales + .map((locale) => ({ locale, stories: byLocale.get(locale) ?? [] })) + .filter((item) => item.stories.length > 0), + }; + } +} diff --git a/src/backend/stubs/controllers/locale_controller.stub b/src/backend/stubs/controllers/locale_controller.stub new file mode 100644 index 00000000..3ce0c29d --- /dev/null +++ b/src/backend/stubs/controllers/locale_controller.stub @@ -0,0 +1,13 @@ +{{{ + exports({ to: app.makePath('app/controllers/locale_controller.ts') }) +}}} +import type { HttpContext } from '@adonisjs/core/http'; +import cms from '#services/cms'; +import { LocaleService } from '@story-cms/kit'; + +export default class LocaleController { + public async index(ctx: HttpContext) { + const service = new LocaleService(cms.config); + return service.availableStories(); + } +} diff --git a/src/backend/stubs/routes/api.stub b/src/backend/stubs/routes/api.stub index 9ff59127..bda4da88 100644 --- a/src/backend/stubs/routes/api.stub +++ b/src/backend/stubs/routes/api.stub @@ -3,6 +3,7 @@ }}} import router from '@adonisjs/core/services/router'; import { middleware } from '#start/kernel'; +import locale from '#start/routes/locale'; const StreamController = () => import('#controllers/streams_controller'); const ChaptersController = () => import('#controllers/chapters_controller'); const PreviewController = () => import('#controllers/preview_controller'); @@ -40,6 +41,8 @@ export default () => { router.get('/page', [PagesController, 'get']); router.get('/invitations', [InvitationsController, 'get']); + + locale(); }) .use(middleware.noIndex()) .prefix('/api/v1'); diff --git a/src/backend/stubs/routes/locale.stub b/src/backend/stubs/routes/locale.stub new file mode 100644 index 00000000..bb44e24d --- /dev/null +++ b/src/backend/stubs/routes/locale.stub @@ -0,0 +1,9 @@ +{{{ + exports({ to: app.makePath('start/routes/locale.ts') }) +}}} +import router from '@adonisjs/core/services/router'; +const LocaleController = () => import('#controllers/locale_controller'); + +export default () => { + router.get('/locale', [LocaleController, 'index']); +}; diff --git a/src/backend/stubs/tests/rest.stub b/src/backend/stubs/tests/rest.stub index 483aefe6..bd285fec 100644 --- a/src/backend/stubs/tests/rest.stub +++ b/src/backend/stubs/tests/rest.stub @@ -31,4 +31,8 @@ Accept: application/json ### GET \{\{ authority \}\}/api/v1/invitations?locale=en HTTP/1.1 +Accept: application/json + +### +GET \{\{ authority \}\}/api/v1/locale HTTP/1.1 Accept: application/json \ No newline at end of file diff --git a/src/backend/stubs/tests/unit/locale_service.stub b/src/backend/stubs/tests/unit/locale_service.stub new file mode 100644 index 00000000..d1cacfb5 --- /dev/null +++ b/src/backend/stubs/tests/unit/locale_service.stub @@ -0,0 +1,140 @@ +{{{ + exports({ to: app.makePath('tests/unit/locale_service.spec.ts') }) +}}} +import { test } from '@japa/runner'; +import testUtils from '@adonisjs/core/services/test_utils'; +import { + LocaleService, + StoryFactory, + StoryLocalisation, + StoryLocalisationFactory, +} from '@story-cms/kit'; +import { testCmsConfig } from '#tests/helpers/cms_mock'; + +const sourceLocale = testCmsConfig.languages[0].locale; +const translationLocale = testCmsConfig.languages[1].locale; +const thirdLocale = testCmsConfig.languages[2].locale; + +async function publishInLocale(storyId: number, locale: string) { + const localisation = await StoryLocalisation.query() + .where('storyId', storyId) + .where('locale', locale) + .first(); + + if (localisation) { + localisation.isPublished = true; + await localisation.save(); + return; + } + + await StoryLocalisationFactory.merge({ + storyId, + locale, + isPublished: true, + title: `Story in ${locale}`, + }).create(); +} + +test.group('LocaleService', (group) => { + group.each.setup(() => testUtils.db().withGlobalTransaction()); + + test('returns slugs grouped by locale', async ({ assert }) => { + const classic = await StoryFactory.with('localisations') + .merge({ slug: 'classic', order: 1 }) + .create(); + const youth = await StoryFactory.with('localisations') + .merge({ slug: 'youth', order: 2 }) + .create(); + + await publishInLocale(classic.id, sourceLocale); + await publishInLocale(youth.id, sourceLocale); + await publishInLocale(classic.id, translationLocale); + + const service = new LocaleService(testCmsConfig); + const { content } = await service.availableStories(); + + const sourceEntry = content.find((item) => item.locale === sourceLocale); + const translationEntry = content.find((item) => item.locale === translationLocale); + + assert.isDefined(sourceEntry); + assert.isDefined(translationEntry); + assert.deepEqual(sourceEntry!.stories, ['classic', 'youth']); + assert.deepEqual(translationEntry!.stories, ['classic']); + }); + + test('respects publish state per locale', async ({ assert }) => { + const story = await StoryFactory.with('localisations') + .merge({ slug: 'express', order: 1 }) + .create(); + + await publishInLocale(story.id, sourceLocale); + + await StoryLocalisationFactory.merge({ + storyId: story.id, + locale: translationLocale, + isPublished: false, + title: 'Historia traducida', + }).create(); + + const service = new LocaleService(testCmsConfig); + const { content } = await service.availableStories(); + + const sourceEntry = content.find((item) => item.locale === sourceLocale); + const translationEntry = content.find((item) => item.locale === translationLocale); + + assert.isDefined(sourceEntry); + assert.deepEqual(sourceEntry!.stories, ['express']); + assert.isUndefined(translationEntry); + }); + + test('omits locales with no published stories', async ({ assert }) => { + const story = await StoryFactory.with('localisations') + .merge({ slug: 'solo', order: 1 }) + .create(); + + await publishInLocale(story.id, sourceLocale); + + const service = new LocaleService(testCmsConfig); + const { content } = await service.availableStories(); + + 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)); + }); + + test('preserves story order within each locale', async ({ assert }) => { + const second = await StoryFactory.with('localisations') + .merge({ slug: 'second', order: 2 }) + .create(); + const first = await StoryFactory.with('localisations') + .merge({ slug: 'first', order: 1 }) + .create(); + + await publishInLocale(first.id, sourceLocale); + await publishInLocale(second.id, sourceLocale); + + const service = new LocaleService(testCmsConfig); + const { content } = await service.availableStories(); + + const sourceEntry = content.find((item) => item.locale === sourceLocale); + assert.isDefined(sourceEntry); + assert.deepEqual(sourceEntry!.stories, ['first', 'second']); + }); + + test('uses configured locale order in response', async ({ assert }) => { + const story = await StoryFactory.with('localisations') + .merge({ slug: 'ordered', order: 1 }) + .create(); + + await publishInLocale(story.id, sourceLocale); + await publishInLocale(story.id, translationLocale); + + const service = new LocaleService(testCmsConfig); + const { content } = await service.availableStories(); + + assert.deepEqual( + content.map((item) => item.locale), + [sourceLocale, translationLocale], + ); + }); +}); diff --git a/src/types.ts b/src/types.ts index 298949a3..e18f355f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -408,6 +408,15 @@ export interface StoryGalleryProps { stories: StoryIndexItem[]; } +export interface LocaleStoriesItem { + locale: string; + stories: string[]; +} + +export interface LocaleStoriesResponse { + content: LocaleStoriesItem[]; +} + export interface DraftEditProps { draft: DraftMeta; // drafts bundle: any; // model From 8ecfbc1d02159bd11f38311eb93d0f697f948d26 Mon Sep 17 00:00:00 2001 From: Timothy Koech Date: Wed, 22 Jul 2026 14:52:28 +0300 Subject: [PATCH 2/8] fix: escape strings --- src/backend/stubs/tests/unit/locale_service.stub | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/stubs/tests/unit/locale_service.stub b/src/backend/stubs/tests/unit/locale_service.stub index d1cacfb5..3a2eae4d 100644 --- a/src/backend/stubs/tests/unit/locale_service.stub +++ b/src/backend/stubs/tests/unit/locale_service.stub @@ -31,7 +31,7 @@ async function publishInLocale(storyId: number, locale: string) { storyId, locale, isPublished: true, - title: `Story in ${locale}`, + title: {{ '`Story in ${locale}`' }}, }).create(); } From 007726dce7fef686f1b5dcf32c3fd6e218be743a Mon Sep 17 00:00:00 2001 From: Timothy Koech Date: Wed, 22 Jul 2026 15:00:49 +0300 Subject: [PATCH 3/8] refactor: remove unused locale stub and update API routes --- src/backend/configure.ts | 1 - src/backend/stubs/routes/api.stub | 4 ++-- src/backend/stubs/routes/locale.stub | 9 --------- 3 files changed, 2 insertions(+), 12 deletions(-) delete mode 100644 src/backend/stubs/routes/locale.stub diff --git a/src/backend/configure.ts b/src/backend/configure.ts index 190d7aa5..88107a7f 100644 --- a/src/backend/configure.ts +++ b/src/backend/configure.ts @@ -137,7 +137,6 @@ export async function configure(command: Configure) { await codemods.makeUsingStub(stubsRoot, 'routes/audience.stub', {}); await codemods.makeUsingStub(stubsRoot, 'routes/preferences.stub', {}); await codemods.makeUsingStub(stubsRoot, 'routes/streams.stub', {}); - await codemods.makeUsingStub(stubsRoot, 'routes/locale.stub', {}); await codemods.makeUsingStub(stubsRoot, 'mail/forget_password.stub', {}); await codemods.makeUsingStub(stubsRoot, 'mail/create_account.stub', {}); diff --git a/src/backend/stubs/routes/api.stub b/src/backend/stubs/routes/api.stub index bda4da88..57cba63c 100644 --- a/src/backend/stubs/routes/api.stub +++ b/src/backend/stubs/routes/api.stub @@ -3,7 +3,6 @@ }}} import router from '@adonisjs/core/services/router'; import { middleware } from '#start/kernel'; -import locale from '#start/routes/locale'; const StreamController = () => import('#controllers/streams_controller'); const ChaptersController = () => import('#controllers/chapters_controller'); const PreviewController = () => import('#controllers/preview_controller'); @@ -11,6 +10,7 @@ const PagesController = () => import('#controllers/pages_controller'); const IndicesController = () => import('#controllers/indices_controller'); const InvitationsController = () => import('#controllers/invitations_controller'); const StoriesController = () => import('#controllers/stories_controller'); +const LocaleController = () => import('#controllers/locale_controller'); const number = /^[0-9]+$/; @@ -42,7 +42,7 @@ export default () => { router.get('/invitations', [InvitationsController, 'get']); - locale(); + router.get('/locale', [LocaleController, 'index']); }) .use(middleware.noIndex()) .prefix('/api/v1'); diff --git a/src/backend/stubs/routes/locale.stub b/src/backend/stubs/routes/locale.stub deleted file mode 100644 index bb44e24d..00000000 --- a/src/backend/stubs/routes/locale.stub +++ /dev/null @@ -1,9 +0,0 @@ -{{{ - exports({ to: app.makePath('start/routes/locale.ts') }) -}}} -import router from '@adonisjs/core/services/router'; -const LocaleController = () => import('#controllers/locale_controller'); - -export default () => { - router.get('/locale', [LocaleController, 'index']); -}; From fbacd488d507a58c60b0a53e7faf9be8d41b4597 Mon Sep 17 00:00:00 2001 From: Timothy Koech Date: Wed, 22 Jul 2026 15:21:44 +0300 Subject: [PATCH 4/8] refactor: update LocaleService method name and adjust controller reference --- .cursor/rules/commit-messages.mdc | 56 +++++++++++++++++++ src/backend/services/locale_service.ts | 2 +- .../stubs/controllers/locale_controller.stub | 2 +- 3 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 .cursor/rules/commit-messages.mdc diff --git a/.cursor/rules/commit-messages.mdc b/.cursor/rules/commit-messages.mdc new file mode 100644 index 00000000..cb10ebb7 --- /dev/null +++ b/.cursor/rules/commit-messages.mdc @@ -0,0 +1,56 @@ +--- +description: Conventional commit message format for this repo +alwaysApply: true +--- + +# Commit Messages + +Use [Conventional Commits](https://www.conventionalcommits.org/) with project-specific types. + +## Format + +``` +: + +[optional body] +``` + +- **Subject**: imperative mood, lowercase after the colon, no trailing period, ~72 chars max +- **Body**: optional; explain *why*, not step-by-step *what* +- **Scope**: omit unless disambiguation helps (this repo rarely uses scopes) + +## Types + +| Type | Alternative considered | Notes | +|------|------------------------|-------| +| `revert` | | reverts a previous commit | +| `fix` | | bug fix, bumps the PATCH release | +| `feat` | | add a new feature, bumps the MINOR release | +| `increment` | improve, ++, incr | functional improvement that is not a feat or a fix | +| `ops` | build, chore, ci | non-functional code changes needed to build or deploy the app | +| `qa` | test, docs | non-functional changes to improve understanding and assurance | +| `refactor` | style, perf | non-functional changes that improve maintainability and efficiency | + +- **increment examples**: remove a feature, add loading state, new screen layout, show popup once only +- **refactor examples**: trim dead wood, move or rename file + +Use the project types above. If tempted to use an alternative (e.g. `chore`, `docs`, `test`, `ci`, `build`, `style`, `perf`), pick the matching project type instead. + +## Examples + +``` +revert: revert "feat: add LocaleService and /api/v1/locale endpoint" +fix: escape strings in locale service test stub +feat: add LocaleService and /api/v1/locale endpoint +increment: show onboarding popup only once per user +refactor: rename availableStories to storiesByLocale +ops: update lock file +qa: add unit tests for storiesByLocale publish filtering +``` + +## When committing + +- One logical change per commit +- Do not commit secrets (`.env`, credentials) +- Only commit when explicitly asked +- Match the style of recent history: `git log --oneline -10` diff --git a/src/backend/services/locale_service.ts b/src/backend/services/locale_service.ts index d05a0459..2d55d107 100644 --- a/src/backend/services/locale_service.ts +++ b/src/backend/services/locale_service.ts @@ -4,7 +4,7 @@ import type { CmsConfig, LocaleStoriesResponse } from '../../types.js'; export class LocaleService { public constructor(protected readonly config: CmsConfig) {} - public async availableStories(): Promise { + public async storiesByLocale(): Promise { const locales = this.config.languages.map((language) => language.locale); const stories = await Story.query() diff --git a/src/backend/stubs/controllers/locale_controller.stub b/src/backend/stubs/controllers/locale_controller.stub index 3ce0c29d..d3ffa74a 100644 --- a/src/backend/stubs/controllers/locale_controller.stub +++ b/src/backend/stubs/controllers/locale_controller.stub @@ -8,6 +8,6 @@ import { LocaleService } from '@story-cms/kit'; export default class LocaleController { public async index(ctx: HttpContext) { const service = new LocaleService(cms.config); - return service.availableStories(); + return service.storiesByLocale(); } } From 26715a205db0c8db10d744a15e8f46704ae966e4 Mon Sep 17 00:00:00 2001 From: Timothy Koech Date: Wed, 22 Jul 2026 15:30:28 +0300 Subject: [PATCH 5/8] refactor: replace availableStories method with storiesByLocale in LocaleService tests --- src/backend/stubs/tests/unit/locale_service.stub | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/backend/stubs/tests/unit/locale_service.stub b/src/backend/stubs/tests/unit/locale_service.stub index 3a2eae4d..9e698a1e 100644 --- a/src/backend/stubs/tests/unit/locale_service.stub +++ b/src/backend/stubs/tests/unit/locale_service.stub @@ -51,7 +51,7 @@ test.group('LocaleService', (group) => { await publishInLocale(classic.id, translationLocale); const service = new LocaleService(testCmsConfig); - const { content } = await service.availableStories(); + const { content } = await service.storiesByLocale(); const sourceEntry = content.find((item) => item.locale === sourceLocale); const translationEntry = content.find((item) => item.locale === translationLocale); @@ -77,7 +77,7 @@ test.group('LocaleService', (group) => { }).create(); const service = new LocaleService(testCmsConfig); - const { content } = await service.availableStories(); + const { content } = await service.storiesByLocale(); const sourceEntry = content.find((item) => item.locale === sourceLocale); const translationEntry = content.find((item) => item.locale === translationLocale); @@ -95,7 +95,7 @@ test.group('LocaleService', (group) => { await publishInLocale(story.id, sourceLocale); const service = new LocaleService(testCmsConfig); - const { content } = await service.availableStories(); + const { content } = await service.storiesByLocale(); assert.isTrue(content.every((item) => item.stories.length > 0)); assert.isUndefined(content.find((item) => item.locale === translationLocale)); @@ -114,7 +114,7 @@ test.group('LocaleService', (group) => { await publishInLocale(second.id, sourceLocale); const service = new LocaleService(testCmsConfig); - const { content } = await service.availableStories(); + const { content } = await service.storiesByLocale(); const sourceEntry = content.find((item) => item.locale === sourceLocale); assert.isDefined(sourceEntry); @@ -130,7 +130,7 @@ test.group('LocaleService', (group) => { await publishInLocale(story.id, translationLocale); const service = new LocaleService(testCmsConfig); - const { content } = await service.availableStories(); + const { content } = await service.storiesByLocale(); assert.deepEqual( content.map((item) => item.locale), From 58ab21b8021de2dd3bfd609cdadc015d3b84e1da Mon Sep 17 00:00:00 2001 From: Timothy Koech Date: Thu, 23 Jul 2026 11:38:20 +0300 Subject: [PATCH 6/8] increment: locale details for app ui ota updates --- src/backend/services/locale_service.ts | 22 ++++++-- .../stubs/tests/unit/locale_service.stub | 56 +++++++++++++++++-- src/frontend/shared/helpers.ts | 26 ++------- src/shared/language_helpers.ts | 19 +++++++ src/types.ts | 7 +++ tests/unit/parseLanguageSpecification.spec.ts | 2 +- 6 files changed, 98 insertions(+), 34 deletions(-) create mode 100644 src/shared/language_helpers.ts diff --git a/src/backend/services/locale_service.ts b/src/backend/services/locale_service.ts index 2d55d107..20b12bf0 100644 --- a/src/backend/services/locale_service.ts +++ b/src/backend/services/locale_service.ts @@ -1,5 +1,6 @@ import Story from '../models/story.js'; -import type { CmsConfig, LocaleStoriesResponse } from '../../types.js'; +import { parseLanguageSpecification } from '../../shared/language_helpers.js'; +import type { CmsConfig, LocaleItem, LocaleStoriesResponse } from '../../types.js'; export class LocaleService { public constructor(protected readonly config: CmsConfig) {} @@ -25,10 +26,19 @@ export class LocaleService { } } - return { - content: locales - .map((locale) => ({ locale, stories: byLocale.get(locale) ?? [] })) - .filter((item) => item.stories.length > 0), - }; + const content = locales + .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: LocaleItem[] = content.map(({ locale }) => { + const spec = languagesByLocale.get(locale)!; + return parseLanguageSpecification(spec); + }); + + return { content, app }; } } diff --git a/src/backend/stubs/tests/unit/locale_service.stub b/src/backend/stubs/tests/unit/locale_service.stub index 9e698a1e..237dd903 100644 --- a/src/backend/stubs/tests/unit/locale_service.stub +++ b/src/backend/stubs/tests/unit/locale_service.stub @@ -9,7 +9,7 @@ import { StoryLocalisation, StoryLocalisationFactory, } from '@story-cms/kit'; -import { testCmsConfig } from '#tests/helpers/cms_mock'; +import { testCmsConfig, createCustomCmsConfig } from '#tests/helpers/cms_mock'; const sourceLocale = testCmsConfig.languages[0].locale; const translationLocale = testCmsConfig.languages[1].locale; @@ -51,7 +51,7 @@ test.group('LocaleService', (group) => { await publishInLocale(classic.id, translationLocale); const service = new LocaleService(testCmsConfig); - const { content } = await service.storiesByLocale(); + const { content, app } = await service.storiesByLocale(); const sourceEntry = content.find((item) => item.locale === sourceLocale); const translationEntry = content.find((item) => item.locale === translationLocale); @@ -60,6 +60,10 @@ 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), + ); }); test('respects publish state per locale', async ({ assert }) => { @@ -77,7 +81,7 @@ test.group('LocaleService', (group) => { }).create(); const service = new LocaleService(testCmsConfig); - const { content } = await service.storiesByLocale(); + const { content, app } = await service.storiesByLocale(); const sourceEntry = content.find((item) => item.locale === sourceLocale); const translationEntry = content.find((item) => item.locale === translationLocale); @@ -85,6 +89,7 @@ test.group('LocaleService', (group) => { assert.isDefined(sourceEntry); assert.deepEqual(sourceEntry!.stories, ['express']); assert.isUndefined(translationEntry); + assert.deepEqual(app.map((item) => item.locale), [sourceLocale]); }); test('omits locales with no published stories', async ({ assert }) => { @@ -95,11 +100,13 @@ test.group('LocaleService', (group) => { await publishInLocale(story.id, sourceLocale); const service = new LocaleService(testCmsConfig); - const { content } = await service.storiesByLocale(); + const { content, app } = await service.storiesByLocale(); 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)); }); test('preserves story order within each locale', async ({ assert }) => { @@ -114,11 +121,12 @@ test.group('LocaleService', (group) => { await publishInLocale(second.id, sourceLocale); const service = new LocaleService(testCmsConfig); - const { content } = await service.storiesByLocale(); + const { content, app } = await service.storiesByLocale(); 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]); }); test('uses configured locale order in response', async ({ assert }) => { @@ -130,11 +138,47 @@ test.group('LocaleService', (group) => { await publishInLocale(story.id, translationLocale); const service = new LocaleService(testCmsConfig); - const { content } = await service.storiesByLocale(); + const { content, app } = await service.storiesByLocale(); assert.deepEqual( content.map((item) => item.locale), [sourceLocale, translationLocale], ); + assert.deepEqual( + app.map((item) => item.locale), + [sourceLocale, translationLocale], + ); + }); + + test('returns parsed name and nativeName in app metadata', async ({ assert }) => { + const config = createCustomCmsConfig({ + languages: [ + { + locale: 'en', + language: 'English', + languageDirection: 'ltr', + }, + { + locale: 'es', + language: 'Spanish - Español', + languageDirection: 'ltr', + }, + ], + }); + + 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.storiesByLocale(); + + assert.deepEqual(app, [ + { locale: 'en', name: 'English', nativeName: 'English' }, + { locale: 'es', name: 'Spanish', nativeName: 'Español' }, + ]); }); }); diff --git a/src/frontend/shared/helpers.ts b/src/frontend/shared/helpers.ts index 59bb103f..f629acb9 100644 --- a/src/frontend/shared/helpers.ts +++ b/src/frontend/shared/helpers.ts @@ -1,5 +1,9 @@ import type { App, Component, PropType } from 'vue'; import { type FieldSpec, type LanguageSpecification } from '../../types'; +import { + LANGUAGE_LABEL_SEPARATOR, + parseLanguageSpecification, +} from '../../shared/language_helpers'; import { BibleBooksMap } from './bibleBooks'; import type { Variant, Story } from 'histoire'; import { DateTime } from 'luxon'; @@ -34,8 +38,6 @@ export const expandShortcuts = (text: string) => { export const padZero = (value: number): string => (value > 9 ? `${value}` : `0${value}`); -const LANGUAGE_LABEL_SEPARATOR = /\s*-\s*|\s*\|\s*/; - export type LanguageSortable = Pick; /** @@ -353,25 +355,7 @@ export function sortLanguagesByDisplayName( return [...languages].sort(compareLanguagesByDisplayName); } -/** Name, native name, and locale from a language specification. */ -export function parseLanguageSpecification(spec: LanguageSpecification): { - name: string; - nativeName: string; - locale: string; -} { - const { language, locale } = spec; - const parts = language.split(LANGUAGE_LABEL_SEPARATOR).map((part) => part.trim()); - - if (parts.length >= 2) { - return { - name: parts[0], - nativeName: parts.slice(1).join(' - '), - locale, - }; - } - - return { name: language, nativeName: language, locale }; -} +export { parseLanguageSpecification }; /** Return the logical parent path for sidebar back navigation, or null to fall back to browser history. */ export function parentPathForBack(pathname: string): string | null { diff --git a/src/shared/language_helpers.ts b/src/shared/language_helpers.ts new file mode 100644 index 00000000..720254cb --- /dev/null +++ b/src/shared/language_helpers.ts @@ -0,0 +1,19 @@ +import type { LanguageSpecification, LocaleItem } from '../types.js'; + +export const LANGUAGE_LABEL_SEPARATOR = /\s*-\s*|\s*\|\s*/; + +/** Name, native name, and locale from a language specification. */ +export function parseLanguageSpecification(spec: LanguageSpecification): LocaleItem { + const { language, locale } = spec; + const parts = language.split(LANGUAGE_LABEL_SEPARATOR).map((part) => part.trim()); + + if (parts.length >= 2) { + return { + name: parts[0], + nativeName: parts.slice(1).join(' - '), + locale, + }; + } + + return { name: language, nativeName: language, locale }; +} diff --git a/src/types.ts b/src/types.ts index e18f355f..be81e749 100644 --- a/src/types.ts +++ b/src/types.ts @@ -413,8 +413,15 @@ export interface LocaleStoriesItem { stories: string[]; } +export interface LocaleItem { + locale: string; + name: string; + nativeName: string; +} + export interface LocaleStoriesResponse { content: LocaleStoriesItem[]; + app: LocaleItem[]; } export interface DraftEditProps { diff --git a/tests/unit/parseLanguageSpecification.spec.ts b/tests/unit/parseLanguageSpecification.spec.ts index 3eb7939f..04c0d478 100644 --- a/tests/unit/parseLanguageSpecification.spec.ts +++ b/tests/unit/parseLanguageSpecification.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from '@playwright/test'; -import { parseLanguageSpecification } from '../../src/frontend/shared/helpers'; +import { parseLanguageSpecification } from '../../src/shared/language_helpers'; import type { LanguageSpecification } from '../../src/types'; test.describe('parseLanguageSpecification', () => { From fa93fef4c41c63771cd1956a1c9aa5a9dd45d369 Mon Sep 17 00:00:00 2001 From: Timothy Koech Date: Thu, 23 Jul 2026 12:35:39 +0300 Subject: [PATCH 7/8] increment: enhance LocaleItem to include languageDirection and update related parsing and tests --- .../stubs/tests/unit/locale_service.stub | 34 +++++++++++++++++-- src/shared/language_helpers.ts | 5 +-- src/types.ts | 3 +- tests/unit/parseLanguageSpecification.spec.ts | 5 +++ 4 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/backend/stubs/tests/unit/locale_service.stub b/src/backend/stubs/tests/unit/locale_service.stub index 237dd903..df89650f 100644 --- a/src/backend/stubs/tests/unit/locale_service.stub +++ b/src/backend/stubs/tests/unit/locale_service.stub @@ -177,8 +177,38 @@ test.group('LocaleService', (group) => { const { app } = await service.storiesByLocale(); assert.deepEqual(app, [ - { locale: 'en', name: 'English', nativeName: 'English' }, - { locale: 'es', name: 'Spanish', nativeName: 'Español' }, + { 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 }) => { + const config = createCustomCmsConfig({ + languages: [ + { + locale: 'ar', + language: 'Arabic - العربية', + languageDirection: 'rtl', + }, + ], + }); + + 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.storiesByLocale(); + + assert.deepEqual(app, [ + { + locale: 'ar', + name: 'Arabic', + nativeName: 'العربية', + languageDirection: 'rtl', + }, ]); }); }); diff --git a/src/shared/language_helpers.ts b/src/shared/language_helpers.ts index 720254cb..1f48f99d 100644 --- a/src/shared/language_helpers.ts +++ b/src/shared/language_helpers.ts @@ -4,7 +4,7 @@ export const LANGUAGE_LABEL_SEPARATOR = /\s*-\s*|\s*\|\s*/; /** Name, native name, and locale from a language specification. */ export function parseLanguageSpecification(spec: LanguageSpecification): LocaleItem { - const { language, locale } = spec; + const { language, locale, languageDirection } = spec; const parts = language.split(LANGUAGE_LABEL_SEPARATOR).map((part) => part.trim()); if (parts.length >= 2) { @@ -12,8 +12,9 @@ export function parseLanguageSpecification(spec: LanguageSpecification): LocaleI name: parts[0], nativeName: parts.slice(1).join(' - '), locale, + languageDirection, }; } - return { name: language, nativeName: language, locale }; + return { name: language, nativeName: language, locale, languageDirection }; } diff --git a/src/types.ts b/src/types.ts index be81e749..5910ed99 100644 --- a/src/types.ts +++ b/src/types.ts @@ -413,8 +413,7 @@ export interface LocaleStoriesItem { stories: string[]; } -export interface LocaleItem { - locale: string; +export interface LocaleItem extends Pick { name: string; nativeName: string; } diff --git a/tests/unit/parseLanguageSpecification.spec.ts b/tests/unit/parseLanguageSpecification.spec.ts index 04c0d478..e199671c 100644 --- a/tests/unit/parseLanguageSpecification.spec.ts +++ b/tests/unit/parseLanguageSpecification.spec.ts @@ -15,6 +15,7 @@ test.describe('parseLanguageSpecification', () => { name: 'English', nativeName: 'English', locale: 'en', + languageDirection: 'ltr', }); }); @@ -29,6 +30,7 @@ test.describe('parseLanguageSpecification', () => { name: 'English', nativeName: 'American', locale: 'en', + languageDirection: 'ltr', }); }); @@ -43,6 +45,7 @@ test.describe('parseLanguageSpecification', () => { name: 'English', nativeName: 'American', locale: 'en', + languageDirection: 'ltr', }); }); @@ -57,6 +60,7 @@ test.describe('parseLanguageSpecification', () => { name: 'Spanish', nativeName: 'Español', locale: 'es', + languageDirection: 'ltr', }); }); @@ -71,6 +75,7 @@ test.describe('parseLanguageSpecification', () => { name: 'German', nativeName: 'Deutsch', locale: 'de', + languageDirection: 'ltr', }); }); }); From d6cb62106f35ae7d364a2a19410add1b12aadf1f Mon Sep 17 00:00:00 2001 From: Timothy Koech Date: Thu, 23 Jul 2026 13:43:02 +0300 Subject: [PATCH 8/8] refactor: rename availableStories to localeIndex and update related types and tests --- .cursor/rules/commit-messages.mdc | 4 ++-- src/backend/services/locale_service.ts | 4 ++-- .../stubs/controllers/locale_controller.stub | 2 +- src/backend/stubs/tests/unit/locale_service.stub | 14 +++++++------- src/types.ts | 11 +++++++---- 5 files changed, 19 insertions(+), 16 deletions(-) diff --git a/.cursor/rules/commit-messages.mdc b/.cursor/rules/commit-messages.mdc index cb10ebb7..bd6aea96 100644 --- a/.cursor/rules/commit-messages.mdc +++ b/.cursor/rules/commit-messages.mdc @@ -43,9 +43,9 @@ revert: revert "feat: add LocaleService and /api/v1/locale endpoint" fix: escape strings in locale service test stub feat: add LocaleService and /api/v1/locale endpoint increment: show onboarding popup only once per user -refactor: rename availableStories to storiesByLocale +refactor: rename availableStories to localeIndex ops: update lock file -qa: add unit tests for storiesByLocale publish filtering +qa: add unit tests for localeIndex publish filtering ``` ## When committing diff --git a/src/backend/services/locale_service.ts b/src/backend/services/locale_service.ts index 20b12bf0..83b5c91b 100644 --- a/src/backend/services/locale_service.ts +++ b/src/backend/services/locale_service.ts @@ -1,11 +1,11 @@ import Story from '../models/story.js'; import { parseLanguageSpecification } from '../../shared/language_helpers.js'; -import type { CmsConfig, LocaleItem, LocaleStoriesResponse } from '../../types.js'; +import type { CmsConfig, LocaleItem, LocaleIndexResponse } from '../../types.js'; export class LocaleService { public constructor(protected readonly config: CmsConfig) {} - public async storiesByLocale(): Promise { + public async localeIndex(): Promise { const locales = this.config.languages.map((language) => language.locale); const stories = await Story.query() diff --git a/src/backend/stubs/controllers/locale_controller.stub b/src/backend/stubs/controllers/locale_controller.stub index d3ffa74a..c3fa4472 100644 --- a/src/backend/stubs/controllers/locale_controller.stub +++ b/src/backend/stubs/controllers/locale_controller.stub @@ -8,6 +8,6 @@ import { LocaleService } from '@story-cms/kit'; export default class LocaleController { public async index(ctx: HttpContext) { const service = new LocaleService(cms.config); - return service.storiesByLocale(); + return service.localeIndex(); } } diff --git a/src/backend/stubs/tests/unit/locale_service.stub b/src/backend/stubs/tests/unit/locale_service.stub index df89650f..7da9b29d 100644 --- a/src/backend/stubs/tests/unit/locale_service.stub +++ b/src/backend/stubs/tests/unit/locale_service.stub @@ -51,7 +51,7 @@ test.group('LocaleService', (group) => { await publishInLocale(classic.id, translationLocale); const service = new LocaleService(testCmsConfig); - const { content, app } = await service.storiesByLocale(); + const { content, app } = await service.localeIndex(); const sourceEntry = content.find((item) => item.locale === sourceLocale); const translationEntry = content.find((item) => item.locale === translationLocale); @@ -81,7 +81,7 @@ test.group('LocaleService', (group) => { }).create(); const service = new LocaleService(testCmsConfig); - const { content, app } = await service.storiesByLocale(); + const { content, app } = await service.localeIndex(); const sourceEntry = content.find((item) => item.locale === sourceLocale); const translationEntry = content.find((item) => item.locale === translationLocale); @@ -100,7 +100,7 @@ test.group('LocaleService', (group) => { await publishInLocale(story.id, sourceLocale); const service = new LocaleService(testCmsConfig); - const { content, app } = await service.storiesByLocale(); + const { content, app } = await service.localeIndex(); assert.isTrue(content.every((item) => item.stories.length > 0)); assert.isUndefined(content.find((item) => item.locale === translationLocale)); @@ -121,7 +121,7 @@ test.group('LocaleService', (group) => { await publishInLocale(second.id, sourceLocale); const service = new LocaleService(testCmsConfig); - const { content, app } = await service.storiesByLocale(); + const { content, app } = await service.localeIndex(); const sourceEntry = content.find((item) => item.locale === sourceLocale); assert.isDefined(sourceEntry); @@ -138,7 +138,7 @@ test.group('LocaleService', (group) => { await publishInLocale(story.id, translationLocale); const service = new LocaleService(testCmsConfig); - const { content, app } = await service.storiesByLocale(); + const { content, app } = await service.localeIndex(); assert.deepEqual( content.map((item) => item.locale), @@ -174,7 +174,7 @@ test.group('LocaleService', (group) => { await publishInLocale(story.id, 'es'); const service = new LocaleService(config); - const { app } = await service.storiesByLocale(); + const { app } = await service.localeIndex(); assert.deepEqual(app, [ { locale: 'en', name: 'English', nativeName: 'English', languageDirection: 'ltr' }, @@ -200,7 +200,7 @@ test.group('LocaleService', (group) => { await publishInLocale(story.id, 'ar'); const service = new LocaleService(config); - const { app } = await service.storiesByLocale(); + const { app } = await service.localeIndex(); assert.deepEqual(app, [ { diff --git a/src/types.ts b/src/types.ts index 5910ed99..8d8ef2de 100644 --- a/src/types.ts +++ b/src/types.ts @@ -408,18 +408,21 @@ export interface StoryGalleryProps { stories: StoryIndexItem[]; } -export interface LocaleStoriesItem { +export interface LocaleContentItem { locale: string; stories: string[]; } -export interface LocaleItem extends Pick { +export interface LocaleItem extends Pick< + LanguageSpecification, + 'locale' | 'languageDirection' +> { name: string; nativeName: string; } -export interface LocaleStoriesResponse { - content: LocaleStoriesItem[]; +export interface LocaleIndexResponse { + content: LocaleContentItem[]; app: LocaleItem[]; }