Skip to content

Commit 9a0ee47

Browse files
QF-3435: Fix ayah reference formatting for RTL locales (#2504)
1 parent b59d4ad commit 9a0ee47

File tree

3 files changed

+281
-27
lines changed

3 files changed

+281
-27
lines changed
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import Reference from '@/types/QuranReflect/Reference';
2+
import { isRTLLocale, toLocalizedNumber } from '@/utils/locale';
3+
import { makeVerseKey } from '@/utils/verse';
4+
5+
const AR_COMMA = '، ';
6+
const isChapterOnly = (r: Reference) => r.from === 0 && r.to === 0;
7+
const hasRange = (r: Reference): r is Reference & { to: number } =>
8+
typeof r.to === 'number' && r.to > 0 && r.to !== r.from;
9+
10+
function buildRTLText(
11+
verseReferences: Reference[],
12+
nonChapterVerseReferences: Reference[],
13+
lang: string,
14+
t: (key: string) => string,
15+
): string {
16+
const chapterNumbers = verseReferences
17+
.filter(isChapterOnly)
18+
.map((r) => toLocalizedNumber(r.chapterId, lang));
19+
20+
const chaptersText = chapterNumbers.join(AR_COMMA);
21+
22+
const verseItems = nonChapterVerseReferences.map((r) => {
23+
const chapterNum = toLocalizedNumber(r.chapterId, lang);
24+
const startAyah = toLocalizedNumber(r.from, lang);
25+
const isRange = hasRange(r);
26+
const endAyah = isRange ? toLocalizedNumber(r.to, lang) : '';
27+
return isRange ? `${startAyah}:${chapterNum}-${endAyah}` : `${startAyah}:${chapterNum}`;
28+
});
29+
30+
const versesText = verseItems.join(AR_COMMA);
31+
32+
if (chaptersText && versesText) {
33+
return `${t('common:surah')} ${chaptersText} ${t('common:and')} ${t(
34+
'common:ayah',
35+
)} ${versesText}`;
36+
}
37+
if (chaptersText) return `${t('common:surah')} ${chaptersText}`;
38+
if (versesText) return `${t('common:ayah')} ${versesText}`;
39+
return '';
40+
}
41+
42+
function buildLTRText(
43+
verseReferences: Reference[],
44+
nonChapterVerseReferences: Reference[],
45+
lang: string,
46+
t: (key: string) => string,
47+
): string {
48+
const chapters = verseReferences
49+
.filter(isChapterOnly)
50+
.map((r) => toLocalizedNumber(r.chapterId, lang));
51+
52+
let text = '';
53+
if (chapters.length > 0) {
54+
text += `${t('common:surah')} ${chapters.join(', ')}`;
55+
}
56+
57+
const verses = nonChapterVerseReferences.map((r) => {
58+
const chapter = toLocalizedNumber(r.chapterId, lang);
59+
const from = toLocalizedNumber(r.from, lang);
60+
const rangeTo = hasRange(r) ? toLocalizedNumber(r.to, lang) : undefined;
61+
return makeVerseKey(chapter, from, rangeTo);
62+
});
63+
64+
if (verses.length > 0) {
65+
if (chapters.length > 0) text += ` ${t('common:and')} `;
66+
text += `${t('common:ayah')} ${verses.join(', ')}`;
67+
}
68+
69+
return text;
70+
}
71+
72+
export default function buildReferredVerseText(
73+
verseReferences: Reference[],
74+
nonChapterVerseReferences: Reference[],
75+
lang: string,
76+
t: (key: string) => string,
77+
): string {
78+
return isRTLLocale(lang)
79+
? buildRTLText(verseReferences, nonChapterVerseReferences, lang, t)
80+
: buildLTRText(verseReferences, nonChapterVerseReferences, lang, t);
81+
}

src/components/QuranReader/ReflectionView/ReflectionItem/AuthorInfo/index.tsx

Lines changed: 5 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,15 @@ import classNames from 'classnames';
44
import useTranslation from 'next-translate/useTranslation';
55

66
import styles from './AuthorInfo.module.scss';
7+
import buildReferredVerseText from './buildReferredVerseText';
78

89
import Link, { LinkVariant } from '@/dls/Link/Link';
910
import ChevronDownIcon from '@/icons/chevron-down.svg';
1011
import VerifiedIcon from '@/icons/verified.svg';
1112
import Reference from '@/types/QuranReflect/Reference';
1213
import { formatDateRelatively } from '@/utils/datetime';
1314
import { logButtonClick } from '@/utils/eventLogger';
14-
import { toLocalizedNumber } from '@/utils/locale';
1515
import { getQuranReflectAuthorUrl } from '@/utils/quranReflect/navigation';
16-
import { makeVerseKey } from '@/utils/verse';
1716

1817
type Props = {
1918
authorUsername: string;
@@ -52,31 +51,10 @@ const AuthorInfo: React.FC<Props> = ({
5251
logButtonClick('reflection_item_author');
5352
};
5453

55-
const referredVerseText = useMemo(() => {
56-
let text = '';
57-
const chapters = verseReferences
58-
.filter((verse) => !verse.from || !verse.to)
59-
.map((verse) => toLocalizedNumber(verse.chapterId, lang));
60-
61-
if (chapters.length > 0) {
62-
text += `${t('common:surah')} ${chapters.join(',')}`;
63-
}
64-
65-
const verses = nonChapterVerseReferences.map((verse) =>
66-
makeVerseKey(
67-
toLocalizedNumber(verse.chapterId, lang),
68-
toLocalizedNumber(verse.from, lang),
69-
toLocalizedNumber(verse.to, lang),
70-
),
71-
);
72-
73-
if (verses.length > 0) {
74-
if (chapters.length > 0) text += ` ${t('common:and')} `;
75-
text += `${t('common:ayah')} ${verses.join(',')}`;
76-
}
77-
78-
return text;
79-
}, [verseReferences, nonChapterVerseReferences, lang, t]);
54+
const referredVerseText = useMemo(
55+
() => buildReferredVerseText(verseReferences, nonChapterVerseReferences, lang, t),
56+
[verseReferences, nonChapterVerseReferences, lang, t],
57+
);
8058

8159
return (
8260
<div className={styles.authorInfo}>
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
/**
2+
* Test Suite: buildReferredVerseText — minimal docs
3+
*
4+
* Validates rendering of Quran references across LTR (en) and RTL (ar/ur/fa).
5+
*
6+
* Covers:
7+
* - Single ayah, multiple ayat, ranges, surah-only, and mixed (surah + ayat).
8+
* - Localized digits via `toLocalizedNumber`.
9+
* - Punctuation: LTR uses ", ", RTL uses "، ".
10+
* - Order: LTR <chapter>:<verse>, RTL <verse>:<chapter>.
11+
* - When both surah-only and ayat exist, inserts a single localized "and".
12+
* - Empty input returns "".
13+
*
14+
*/
15+
16+
import { describe, it, expect, vi, beforeAll } from 'vitest';
17+
18+
/* eslint-disable max-lines, react-func/max-lines-per-function */
19+
import buildReferredVerseText from '../../components/QuranReader/ReflectionView/ReflectionItem/AuthorInfo/buildReferredVerseText';
20+
21+
import type Reference from '@/types/QuranReflect/Reference';
22+
import { isRTLLocale, toLocalizedNumber } from '@/utils/locale';
23+
import { makeVerseKey } from '@/utils/verse';
24+
25+
type CommonDict = Record<'ayah' | 'surah' | 'and', string>;
26+
27+
const en: CommonDict = { ayah: 'Ayah', surah: 'Surah', and: 'and' };
28+
const ar: CommonDict = { ayah: 'آية', surah: 'سورة', and: 'و' };
29+
const ur: CommonDict = { ayah: 'آیت', surah: 'سورہ', and: 'اور' };
30+
const fa: CommonDict = { ayah: 'آیه', surah: 'سوره', and: 'و' };
31+
32+
const maps: Record<string, CommonDict> = { en, ar, ur, fa };
33+
34+
vi.mock('next-translate/getT', () => ({
35+
default: async (lang: string) => {
36+
const dict = maps[lang] ?? maps.en;
37+
return (key: string) => {
38+
const plainKey = key.includes(':') ? key.split(':')[1] : key;
39+
return dict[plainKey] ?? key;
40+
};
41+
},
42+
}));
43+
44+
const makeRef = (chapterId: number, from: number, to?: number): Reference => ({
45+
chapterId,
46+
from,
47+
to: typeof to === 'number' ? to : 0,
48+
id:
49+
to === undefined
50+
? `surah-${chapterId}${from ? `-${from}` : ''}`
51+
: `surah-${chapterId}-${from}:${to}`,
52+
});
53+
54+
const COMMA = { ltr: ', ', rtl: '، ' } as const;
55+
56+
/* eslint-disable max-lines, react-func/max-lines-per-function */
57+
// ---- LTR (en) ----
58+
describe('buildReferredVerseText — LTR (en)', () => {
59+
let t: (k: string) => string;
60+
beforeAll(async () => {
61+
const { default: getT } = await import('next-translate/getT');
62+
t = await getT('en', 'common');
63+
});
64+
65+
it('single ayah', () => {
66+
const verses = [makeRef(2, 1, 1)];
67+
const out = buildReferredVerseText(verses, verses, 'en', t);
68+
expect(out).toBe(`${t('common:ayah')} ${makeVerseKey('2', '1')}`);
69+
});
70+
71+
it('multiple ayat, stable order, LTR comma', () => {
72+
const all = [makeRef(2, 1, 1), makeRef(1, 1, 1)];
73+
const out = buildReferredVerseText(all, all, 'en', t);
74+
const expected = `${t('common:ayah')} ${makeVerseKey('2', '1')}${COMMA.ltr}${makeVerseKey(
75+
'1',
76+
'1',
77+
)}`;
78+
expect(out).toBe(expected);
79+
});
80+
81+
it('mixed: surah-only + ayat (includes a range)', () => {
82+
const verseRefs = [makeRef(18, 4, 5), makeRef(2, 0, 0), makeRef(8, 12, 12)];
83+
const nonChapters = [makeRef(18, 4, 5), makeRef(8, 12, 12)];
84+
const out = buildReferredVerseText(verseRefs, nonChapters, 'en', t);
85+
const surahs = `${t('common:surah')} ${toLocalizedNumber(2, 'en')}`;
86+
const verses = `${makeVerseKey('18', '4', '5')}${COMMA.ltr}${makeVerseKey('8', '12')}`;
87+
expect(out).toBe(`${surahs} ${t('common:and')} ${t('common:ayah')} ${verses}`);
88+
});
89+
90+
it('surah-only', () => {
91+
const out = buildReferredVerseText([makeRef(36, 0, 0)], [], 'en', t);
92+
expect(out).toBe(`${t('common:surah')} ${toLocalizedNumber(36, 'en')}`);
93+
});
94+
});
95+
96+
// ---- RTL (ar / ur / fa) ----
97+
describe.each([
98+
{ lang: 'ar', comma: COMMA.rtl },
99+
{ lang: 'ur', comma: COMMA.rtl },
100+
{ lang: 'fa', comma: COMMA.rtl },
101+
] as const)('buildReferredVerseText — RTL (%s)', ({ lang, comma }) => {
102+
let t: (k: string) => string;
103+
beforeAll(async () => {
104+
const { default: getT } = await import('next-translate/getT');
105+
t = await getT(lang, 'common');
106+
});
107+
108+
it('flags RTL locale', () => {
109+
expect(isRTLLocale(lang)).toBe(true);
110+
});
111+
112+
it('single ayah uses localized numerals and ":" order (verse:chapter)', () => {
113+
const r = [makeRef(2, 1, 1)];
114+
const out = buildReferredVerseText(r, r, lang, t);
115+
const expected = `${t('common:ayah')} ${toLocalizedNumber(1, lang)}:${toLocalizedNumber(
116+
2,
117+
lang,
118+
)}`;
119+
expect(out).toBe(expected);
120+
});
121+
122+
it('multiple ayat join with Arabic comma and single space', () => {
123+
const all = [makeRef(2, 1, 1), makeRef(1, 1, 1)];
124+
const out = buildReferredVerseText(all, all, lang, t);
125+
const first = `${toLocalizedNumber(1, lang)}:${toLocalizedNumber(2, lang)}`;
126+
const second = `${toLocalizedNumber(1, lang)}:${toLocalizedNumber(1, lang)}`;
127+
128+
// enforce exact comma character and spacing
129+
expect(comma).toBe('، ');
130+
expect(out).toBe(`${t('common:ayah')} ${first}${comma}${second}`);
131+
});
132+
133+
it('mixed: surah-only + ayat (+range) with localized digits', () => {
134+
const verseRefs = [makeRef(18, 4, 5), makeRef(2, 0, 0), makeRef(8, 12, 12)];
135+
const nonChapters = [makeRef(18, 4, 5), makeRef(8, 12, 12)];
136+
const out = buildReferredVerseText(verseRefs, nonChapters, lang, t);
137+
138+
const surahs = `${t('common:surah')} ${toLocalizedNumber(2, lang)}`;
139+
const v1 = `${toLocalizedNumber(4, lang)}:${toLocalizedNumber(18, lang)}-${toLocalizedNumber(
140+
5,
141+
lang,
142+
)}`;
143+
const v2 = `${toLocalizedNumber(12, lang)}:${toLocalizedNumber(8, lang)}`;
144+
expect(out).toBe(`${surahs} ${t('common:and')} ${t('common:ayah')} ${v1}${comma}${v2}`);
145+
});
146+
});
147+
148+
// ---- edge cases ----
149+
describe('buildReferredVerseText — edge cases', () => {
150+
it('empty input → empty string (all locales)', async () => {
151+
const { default: getT } = await import('next-translate/getT');
152+
await Promise.all(
153+
(['en', 'ar', 'ur', 'fa'] as const).map(async (lang) => {
154+
const t = await getT(lang, 'common');
155+
expect(buildReferredVerseText([], [], lang, t)).toBe('');
156+
}),
157+
);
158+
});
159+
160+
it('range where from === to is rendered as single ayah', async () => {
161+
const { default: getT } = await import('next-translate/getT');
162+
const t = await getT('en', 'common');
163+
const verses = [makeRef(2, 5, 5)];
164+
const out = buildReferredVerseText(verses, verses, 'en', t);
165+
expect(out).toBe(`${t('common:ayah')} ${makeVerseKey('2', '5')}`);
166+
});
167+
168+
it('invalid range (from > to) — documents current behavior (no crash)', async () => {
169+
const { default: getT } = await import('next-translate/getT');
170+
const t = await getT('en', 'common');
171+
const verses = [makeRef(2, 10, 5)];
172+
const out = buildReferredVerseText(verses, verses, 'en', t);
173+
expect(typeof out).toBe('string');
174+
});
175+
176+
it('no extra spaces or trailing commas (LTR)', async () => {
177+
const { default: getT } = await import('next-translate/getT');
178+
const t = await getT('en', 'common');
179+
const all = [makeRef(2, 1, 1), makeRef(1, 1, 1)];
180+
const out = buildReferredVerseText(all, all, 'en', t);
181+
expect(out).not.toMatch(/\s{2,}/);
182+
expect(out.endsWith(',')).toBe(false);
183+
expect(out.endsWith(' ')).toBe(false);
184+
});
185+
186+
it('no extra spaces or trailing commas (RTL)', async () => {
187+
const { default: getT } = await import('next-translate/getT');
188+
const t = await getT('ar', 'common');
189+
const all = [makeRef(2, 1, 1), makeRef(1, 1, 1)];
190+
const out = buildReferredVerseText(all, all, 'ar', t);
191+
expect(out).not.toMatch(/\s{2,}/);
192+
expect(out.endsWith('،')).toBe(false);
193+
expect(out.endsWith(' ')).toBe(false);
194+
});
195+
});

0 commit comments

Comments
 (0)