diff --git a/.cursor/rules/commit-messages.mdc b/.cursor/rules/commit-messages.mdc new file mode 100644 index 00000000..bd6aea96 --- /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 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` diff --git a/src/backend/configure.ts b/src/backend/configure.ts index 87a1acc9..88107a7f 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', {}); @@ -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', {}); 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..83b5c91b --- /dev/null +++ b/src/backend/services/locale_service.ts @@ -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 { + 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 }; + } +} diff --git a/src/backend/stubs/controllers/locale_controller.stub b/src/backend/stubs/controllers/locale_controller.stub new file mode 100644 index 00000000..c3fa4472 --- /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.localeIndex(); + } +} diff --git a/src/backend/stubs/routes/api.stub b/src/backend/stubs/routes/api.stub index 9ff59127..57cba63c 100644 --- a/src/backend/stubs/routes/api.stub +++ b/src/backend/stubs/routes/api.stub @@ -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]+$/; @@ -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'); 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..7da9b29d --- /dev/null +++ b/src/backend/stubs/tests/unit/locale_service.stub @@ -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', + }, + ]); + }); +}); 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..1f48f99d --- /dev/null +++ b/src/shared/language_helpers.ts @@ -0,0 +1,20 @@ +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, languageDirection } = 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, + languageDirection, + }; + } + + return { name: language, nativeName: language, locale, languageDirection }; +} diff --git a/src/types.ts b/src/types.ts index 298949a3..8d8ef2de 100644 --- a/src/types.ts +++ b/src/types.ts @@ -408,6 +408,24 @@ export interface StoryGalleryProps { stories: StoryIndexItem[]; } +export interface LocaleContentItem { + locale: string; + stories: string[]; +} + +export interface LocaleItem extends Pick< + LanguageSpecification, + 'locale' | 'languageDirection' +> { + name: string; + nativeName: string; +} + +export interface LocaleIndexResponse { + content: LocaleContentItem[]; + app: LocaleItem[]; +} + export interface DraftEditProps { draft: DraftMeta; // drafts bundle: any; // model diff --git a/tests/unit/parseLanguageSpecification.spec.ts b/tests/unit/parseLanguageSpecification.spec.ts index 3eb7939f..e199671c 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', () => { @@ -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', }); }); });