Skip to content
56 changes: 56 additions & 0 deletions .cursor/rules/commit-messages.mdc
Original file line number Diff line number Diff line change
@@ -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

```
<type>: <subject>

[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 localeIndex
ops: update lock file
qa: add unit tests for localeIndex 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`
2 changes: 2 additions & 0 deletions src/backend/configure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {});

Expand Down Expand Up @@ -169,6 +170,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', {});
Expand Down
1 change: 1 addition & 0 deletions src/backend/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
44 changes: 44 additions & 0 deletions src/backend/services/locale_service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import Story from '../models/story.js';
import { parseLanguageSpecification } from '../../shared/language_helpers.js';
import type { CmsConfig, LocaleItem, LocaleIndexResponse } from '../../types.js';

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

public async localeIndex(): Promise<LocaleIndexResponse> {
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);
}
}

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 };
}
}
13 changes: 13 additions & 0 deletions src/backend/stubs/controllers/locale_controller.stub
Original file line number Diff line number Diff line change
@@ -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.localeIndex();
}
}
3 changes: 3 additions & 0 deletions src/backend/stubs/routes/api.stub
Original file line number Diff line number Diff line change
Expand Up @@ -10,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]+$/;

Expand Down Expand Up @@ -40,6 +41,8 @@ export default () => {
router.get('/page', [PagesController, 'get']);

router.get('/invitations', [InvitationsController, 'get']);

router.get('/locale', [LocaleController, 'index']);
})
.use(middleware.noIndex())
.prefix('/api/v1');
Expand Down
4 changes: 4 additions & 0 deletions src/backend/stubs/tests/rest.stub
Original file line number Diff line number Diff line change
Expand Up @@ -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
214 changes: 214 additions & 0 deletions src/backend/stubs/tests/unit/locale_service.stub
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
{{{
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, createCustomCmsConfig } 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, app } = await service.localeIndex();

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']);
assert.deepEqual(
app.map((item) => item.locale),
content.map((item) => item.locale),
);
});

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, app } = await service.localeIndex();

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);
assert.deepEqual(app.map((item) => item.locale), [sourceLocale]);
});

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, app } = await service.localeIndex();

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 }) => {
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, app } = await service.localeIndex();

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 }) => {
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, app } = await service.localeIndex();

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.localeIndex();

assert.deepEqual(app, [
{ 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.localeIndex();

assert.deepEqual(app, [
{
locale: 'ar',
name: 'Arabic',
nativeName: 'العربية',
languageDirection: 'rtl',
},
]);
});
});
Loading
Loading