@sabiq/utils 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +21 -0
- package/README.md +2 -0
- package/dist/cjs/hooks/index.d.ts +1 -0
- package/dist/cjs/hooks/index.js +4 -0
- package/dist/cjs/hooks/useRecitationValidator.d.ts +25 -0
- package/dist/cjs/hooks/useRecitationValidator.js +392 -0
- package/dist/cjs/index.d.ts +2 -0
- package/dist/cjs/index.js +5 -0
- package/dist/cjs/utils/arabic.d.ts +2 -0
- package/dist/cjs/utils/arabic.js +33 -0
- package/dist/cjs/utils/index.d.ts +4 -0
- package/dist/cjs/utils/index.js +7 -0
- package/dist/cjs/utils/levenshtein.d.ts +2 -0
- package/dist/cjs/utils/levenshtein.js +32 -0
- package/dist/cjs/utils/validation.d.ts +28 -0
- package/dist/cjs/utils/validation.js +241 -0
- package/dist/cjs/utils/wordAlternatives.d.ts +41 -0
- package/dist/cjs/utils/wordAlternatives.js +230 -0
- package/dist/esm/hooks/index.d.ts +1 -0
- package/dist/esm/hooks/index.js +1 -0
- package/dist/esm/hooks/useRecitationValidator.d.ts +25 -0
- package/dist/esm/hooks/useRecitationValidator.js +388 -0
- package/dist/esm/index.d.ts +2 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/utils/arabic.d.ts +2 -0
- package/dist/esm/utils/arabic.js +28 -0
- package/dist/esm/utils/index.d.ts +4 -0
- package/dist/esm/utils/index.js +4 -0
- package/dist/esm/utils/levenshtein.d.ts +2 -0
- package/dist/esm/utils/levenshtein.js +27 -0
- package/dist/esm/utils/validation.d.ts +28 -0
- package/dist/esm/utils/validation.js +236 -0
- package/dist/esm/utils/wordAlternatives.d.ts +41 -0
- package/dist/esm/utils/wordAlternatives.js +226 -0
- package/package.json +58 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { levenshteinDistance } from "./levenshtein";
|
|
2
|
+
import { normalizeArabic } from "./arabic";
|
|
3
|
+
import { wordAlternatives, getAlternativesFor, } from "./wordAlternatives";
|
|
4
|
+
/**
|
|
5
|
+
* Standard threshold: 30% of the word's length, rounded up.
|
|
6
|
+
*/
|
|
7
|
+
export function defaultThreshold(word) {
|
|
8
|
+
return Math.ceil(word.length * 0.3);
|
|
9
|
+
}
|
|
10
|
+
function isBetterAsMerge(prevSpoken, currentSpoken, prevExpected, currentExpected) {
|
|
11
|
+
const currNorm = joinForAltCompare(currentSpoken);
|
|
12
|
+
if (currNorm.length === 0 || currNorm.length > 3)
|
|
13
|
+
return false;
|
|
14
|
+
const prevNorm = joinForAltCompare(prevSpoken);
|
|
15
|
+
const expPrevNorm = joinForAltCompare(prevExpected);
|
|
16
|
+
const expCurrNorm = joinForAltCompare(currentExpected);
|
|
17
|
+
if (prevNorm.length === 0 ||
|
|
18
|
+
expPrevNorm.length === 0 ||
|
|
19
|
+
expCurrNorm.length === 0) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
const allowedPrev = defaultThreshold(expPrevNorm);
|
|
23
|
+
const mergedLen = prevNorm.length + currNorm.length;
|
|
24
|
+
if (mergedLen > expPrevNorm.length + allowedPrev)
|
|
25
|
+
return false;
|
|
26
|
+
if (expPrevNorm.length > mergedLen + allowedPrev)
|
|
27
|
+
return false;
|
|
28
|
+
const dPrev = levenshteinDistance(prevNorm, expPrevNorm);
|
|
29
|
+
if (dPrev === 0)
|
|
30
|
+
return false;
|
|
31
|
+
const merged = prevNorm + currNorm;
|
|
32
|
+
const dMerged = levenshteinDistance(merged, expPrevNorm);
|
|
33
|
+
if (dMerged > allowedPrev || dMerged >= dPrev)
|
|
34
|
+
return false;
|
|
35
|
+
// Tiebreaker: only override CORRECT if merge fits prev STRICTLY better
|
|
36
|
+
// than current fits its own expected. Without this, a coincidentally
|
|
37
|
+
// similar current (e.g. "في" sharing a leading char with "ني" in "مكني")
|
|
38
|
+
// would falsely get absorbed.
|
|
39
|
+
const dCurrent = levenshteinDistance(currNorm, expCurrNorm);
|
|
40
|
+
return dMerged < dCurrent;
|
|
41
|
+
}
|
|
42
|
+
function matchesMain(spoken, expected) {
|
|
43
|
+
const a = normalizeArabic(spoken);
|
|
44
|
+
const b = normalizeArabic(expected);
|
|
45
|
+
return levenshteinDistance(a, b) <= defaultThreshold(b);
|
|
46
|
+
}
|
|
47
|
+
function isPrefixOfMain(spoken, expected) {
|
|
48
|
+
const a = normalizeArabic(spoken);
|
|
49
|
+
const b = normalizeArabic(expected);
|
|
50
|
+
if (a.length === 0 || a.length >= b.length)
|
|
51
|
+
return false;
|
|
52
|
+
const prefix = b.slice(0, a.length);
|
|
53
|
+
return levenshteinDistance(a, prefix) <= defaultThreshold(prefix);
|
|
54
|
+
}
|
|
55
|
+
function joinForAltCompare(s) {
|
|
56
|
+
return normalizeArabic(s).replace(/[\s\u200B-\u200F\u202A-\u202E\u00A0]/g, "");
|
|
57
|
+
}
|
|
58
|
+
function matchesAnyAlternative(spoken, alts) {
|
|
59
|
+
const a = joinForAltCompare(spoken);
|
|
60
|
+
for (const alt of alts) {
|
|
61
|
+
const altNorm = joinForAltCompare(alt);
|
|
62
|
+
const distance = levenshteinDistance(a, altNorm);
|
|
63
|
+
const allowed = defaultThreshold(altNorm);
|
|
64
|
+
const lenDiff = Math.abs(a.length - altNorm.length);
|
|
65
|
+
// For short joined alts (≤ 4 chars — the compact muqatta'at forms),
|
|
66
|
+
// require exact equality. Each char in a compact form represents one
|
|
67
|
+
// entire letter of the muqatta'at, so even a single edit completely
|
|
68
|
+
// changes the meaning. Without this, a single spoken letter like "الف"
|
|
69
|
+
// (3 chars) falsely full-matches the compact alt "الم" (3 chars, also
|
|
70
|
+
// 1 edit away), preventing the prefix path from collecting subsequent
|
|
71
|
+
// letters.
|
|
72
|
+
if (altNorm.length <= 4) {
|
|
73
|
+
if (distance === 0)
|
|
74
|
+
return true;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (distance <= allowed && lenDiff <= Math.ceil(allowed / 2)) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
function isPrefixOfAnyAlternative(spoken, alts) {
|
|
84
|
+
const a = joinForAltCompare(spoken);
|
|
85
|
+
if (a.length === 0)
|
|
86
|
+
return false;
|
|
87
|
+
for (const alt of alts) {
|
|
88
|
+
const altNorm = joinForAltCompare(alt);
|
|
89
|
+
if (a.length >= altNorm.length)
|
|
90
|
+
continue;
|
|
91
|
+
const prefix = altNorm.slice(0, a.length);
|
|
92
|
+
if (levenshteinDistance(a, prefix) <= defaultThreshold(prefix))
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Inverse of isPrefixOfMain: is the EXPECTED word a prefix of SPOKEN?
|
|
99
|
+
* Detects when STT combined two mushaf words into one token.
|
|
100
|
+
*/
|
|
101
|
+
function isContainedIn(spoken, expected) {
|
|
102
|
+
const a = normalizeArabic(spoken);
|
|
103
|
+
const b = normalizeArabic(expected);
|
|
104
|
+
if (b.length < 2)
|
|
105
|
+
return false;
|
|
106
|
+
if (b.length >= a.length)
|
|
107
|
+
return false;
|
|
108
|
+
const remainderLen = a.length - b.length;
|
|
109
|
+
if (remainderLen < 2)
|
|
110
|
+
return false;
|
|
111
|
+
const prefix = a.slice(0, b.length);
|
|
112
|
+
// First-character anchor. The single most reliable signal that prefix
|
|
113
|
+
// is structurally aligned with expected, not coincidentally similar.
|
|
114
|
+
if (prefix[0] !== b[0])
|
|
115
|
+
return false;
|
|
116
|
+
return levenshteinDistance(prefix, b) <= defaultThreshold(b);
|
|
117
|
+
}
|
|
118
|
+
function splitContained(spoken, expected) {
|
|
119
|
+
const a = normalizeArabic(spoken);
|
|
120
|
+
const b = normalizeArabic(expected);
|
|
121
|
+
return a.slice(b.length);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Word-split merge detection.
|
|
125
|
+
*
|
|
126
|
+
* STT sometimes splits one mushaf word into two short tokens (e.g. "يَٰذَا"
|
|
127
|
+
* → "يا" + "ذا"). The first token may have been accepted as a fuzzy match
|
|
128
|
+
* for the mushaf word; the second now has nothing to match against.
|
|
129
|
+
*
|
|
130
|
+
* We detect this by checking whether `prevSpoken + currentSpoken` matches
|
|
131
|
+
* the previous expected word *strictly better* than `prevSpoken` alone.
|
|
132
|
+
*
|
|
133
|
+
* The strictness gate (dMerged < dPrev) is critical:
|
|
134
|
+
* - If prev was an exact match (dPrev = 0), no merge is possible — current
|
|
135
|
+
* must be a separate word.
|
|
136
|
+
* - If prev was fuzzy (dPrev > 0), merge fires only when adding current
|
|
137
|
+
* improves the distance, which is exactly the STT-split signal.
|
|
138
|
+
*/
|
|
139
|
+
function matchesAsMerge(prevSpoken, currentSpoken, prevExpected) {
|
|
140
|
+
const prevNorm = joinForAltCompare(prevSpoken);
|
|
141
|
+
const currNorm = joinForAltCompare(currentSpoken);
|
|
142
|
+
const expNorm = joinForAltCompare(prevExpected);
|
|
143
|
+
if (prevNorm.length === 0 || currNorm.length === 0 || expNorm.length === 0) {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
if (currNorm.length > expNorm.length) {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
const allowed = defaultThreshold(expNorm);
|
|
150
|
+
const mergedLen = prevNorm.length + currNorm.length;
|
|
151
|
+
// Length-difference lower bound on Levenshtein. If merged is too far
|
|
152
|
+
// from expected in raw length, distance can't be within threshold.
|
|
153
|
+
if (mergedLen > expNorm.length + allowed)
|
|
154
|
+
return false;
|
|
155
|
+
if (expNorm.length > mergedLen + allowed)
|
|
156
|
+
return false;
|
|
157
|
+
const merged = prevNorm + currNorm;
|
|
158
|
+
const dMerged = levenshteinDistance(merged, expNorm);
|
|
159
|
+
if (dMerged > allowed)
|
|
160
|
+
return false;
|
|
161
|
+
const dPrev = levenshteinDistance(prevNorm, expNorm);
|
|
162
|
+
// Improvement gate: merged must be strictly closer, OR same distance
|
|
163
|
+
// with strictly more length (which means current contributed signal
|
|
164
|
+
// that aligned with the tail of expected).
|
|
165
|
+
return dMerged < dPrev || (dMerged === dPrev && mergedLen > prevNorm.length);
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Validate a buffered spoken token against the expected word at currentIndex.
|
|
169
|
+
*
|
|
170
|
+
* @param lastCorrectToken The raw token that produced the most recent
|
|
171
|
+
* "correct" outcome at currentIndex - 1. Used by the merge mechanism
|
|
172
|
+
* to detect STT word-splits. Pass null if not tracking, or if the
|
|
173
|
+
* previous outcome was anything other than "correct".
|
|
174
|
+
*/
|
|
175
|
+
export function validateRecitation(buffer, expected, currentIndex, alternatives = wordAlternatives, lastCorrectToken = null) {
|
|
176
|
+
const trimmed = buffer.trim();
|
|
177
|
+
const target = expected[currentIndex];
|
|
178
|
+
if (!target) {
|
|
179
|
+
return { status: "wrong", advance: true, newBuffer: "" };
|
|
180
|
+
}
|
|
181
|
+
const position = { surah: target.surah, verse: target.verse };
|
|
182
|
+
const alts = getAlternativesFor(target.text, position.surah, position.verse, alternatives);
|
|
183
|
+
const tryMerge = () => {
|
|
184
|
+
if (!lastCorrectToken || currentIndex === 0)
|
|
185
|
+
return null;
|
|
186
|
+
const prevExpected = expected[currentIndex - 1];
|
|
187
|
+
if (!prevExpected)
|
|
188
|
+
return null;
|
|
189
|
+
if (matchesAsMerge(lastCorrectToken, trimmed, prevExpected.text)) {
|
|
190
|
+
return { status: "merge", advance: false, newBuffer: "" };
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
};
|
|
194
|
+
// === Muqatta'at path ===
|
|
195
|
+
if (alts.length > 0) {
|
|
196
|
+
if (matchesAnyAlternative(trimmed, alts)) {
|
|
197
|
+
return { status: "correct", advance: true, newBuffer: "" };
|
|
198
|
+
}
|
|
199
|
+
if (normalizeArabic(trimmed) === normalizeArabic(target.text)) {
|
|
200
|
+
return { status: "correct", advance: true, newBuffer: "" };
|
|
201
|
+
}
|
|
202
|
+
if (isPrefixOfAnyAlternative(trimmed, alts)) {
|
|
203
|
+
return { status: "pending", advance: false, newBuffer: trimmed };
|
|
204
|
+
}
|
|
205
|
+
const merge = tryMerge();
|
|
206
|
+
if (merge)
|
|
207
|
+
return merge;
|
|
208
|
+
return { status: "wrong", advance: true, newBuffer: "" };
|
|
209
|
+
}
|
|
210
|
+
// === Normal main-word path ===
|
|
211
|
+
if (matchesMain(trimmed, target.text)) {
|
|
212
|
+
if (lastCorrectToken && currentIndex > 0) {
|
|
213
|
+
const prevExpected = expected[currentIndex - 1];
|
|
214
|
+
if (prevExpected &&
|
|
215
|
+
isBetterAsMerge(lastCorrectToken, trimmed, prevExpected.text, target.text)) {
|
|
216
|
+
return { status: "merge", advance: false, newBuffer: "" };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return { status: "correct", advance: true, newBuffer: "" };
|
|
220
|
+
}
|
|
221
|
+
if (isContainedIn(trimmed, target.text)) {
|
|
222
|
+
return {
|
|
223
|
+
status: "split",
|
|
224
|
+
advance: true,
|
|
225
|
+
newBuffer: "",
|
|
226
|
+
remainder: splitContained(trimmed, target.text),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
if (isPrefixOfMain(trimmed, target.text)) {
|
|
230
|
+
return { status: "pending", advance: false, newBuffer: trimmed };
|
|
231
|
+
}
|
|
232
|
+
const merge = tryMerge();
|
|
233
|
+
if (merge)
|
|
234
|
+
return merge;
|
|
235
|
+
return { status: "wrong", advance: true, newBuffer: "" };
|
|
236
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export interface WordAlternative {
|
|
2
|
+
/** The expected word in the mushaf (or its normalized form). */
|
|
3
|
+
word: string;
|
|
4
|
+
/** Acceptable spoken alternatives. */
|
|
5
|
+
alternatives: string[];
|
|
6
|
+
/** Optional scope: only apply at this surah:verse. */
|
|
7
|
+
scope?: {
|
|
8
|
+
surah: number;
|
|
9
|
+
verse: number;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The الحروف المقطعة (muqatta'at) appear ONLY at ayah 1 of specific surahs
|
|
14
|
+
* (and in one case, ayah 2 of الشورى — عسق). They look identical to normal
|
|
15
|
+
* Arabic words like أَلَمْ in other contexts, so we must scope them.
|
|
16
|
+
*
|
|
17
|
+
* Each muqatta'at entry carries up to three alt forms, listed in order of
|
|
18
|
+
* expected real-world frequency. Whichever matches first wins, so order
|
|
19
|
+
* matters for performance only — correctness is unaffected by ordering.
|
|
20
|
+
*
|
|
21
|
+
* 1. Realistic hybrid — short letters compact (ك, ه, ط, ح, ر), long
|
|
22
|
+
* letters spelled out (ميم, لام, سين, عين, صاد, قاف, نون, كاف).
|
|
23
|
+
* This is what reciters actually say, and what STT actually delivers.
|
|
24
|
+
*
|
|
25
|
+
* 2. Pure compact — every letter as a single consonant char. After
|
|
26
|
+
* joinForAltCompare strips spaces this becomes the mushaf form
|
|
27
|
+
* itself, giving exact prefix matching for letter-by-letter input.
|
|
28
|
+
*
|
|
29
|
+
* 3. Full letter names — every letter spelled out (الف, ها, يا, etc.).
|
|
30
|
+
* Backstop for STT that delivers full phonetic forms.
|
|
31
|
+
*
|
|
32
|
+
* Single-letter muqatta'at (ص، ق، ن) only need two forms since compact
|
|
33
|
+
* and mushaf collapse to the same single character.
|
|
34
|
+
*/
|
|
35
|
+
export declare const wordAlternatives: WordAlternative[];
|
|
36
|
+
/**
|
|
37
|
+
* Look up alternatives for an expected word at a specific position.
|
|
38
|
+
* Returns alternatives only if a scoped entry matches the surah:verse,
|
|
39
|
+
* or if there's an unscoped entry (for future generic alternatives).
|
|
40
|
+
*/
|
|
41
|
+
export declare function getAlternativesFor(expected: string, surah: number, verse: number, alternatives?: WordAlternative[]): string[];
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// utils/wordAlternatives.ts
|
|
2
|
+
import { normalizeArabic } from "./arabic";
|
|
3
|
+
/**
|
|
4
|
+
* The الحروف المقطعة (muqatta'at) appear ONLY at ayah 1 of specific surahs
|
|
5
|
+
* (and in one case, ayah 2 of الشورى — عسق). They look identical to normal
|
|
6
|
+
* Arabic words like أَلَمْ in other contexts, so we must scope them.
|
|
7
|
+
*
|
|
8
|
+
* Each muqatta'at entry carries up to three alt forms, listed in order of
|
|
9
|
+
* expected real-world frequency. Whichever matches first wins, so order
|
|
10
|
+
* matters for performance only — correctness is unaffected by ordering.
|
|
11
|
+
*
|
|
12
|
+
* 1. Realistic hybrid — short letters compact (ك, ه, ط, ح, ر), long
|
|
13
|
+
* letters spelled out (ميم, لام, سين, عين, صاد, قاف, نون, كاف).
|
|
14
|
+
* This is what reciters actually say, and what STT actually delivers.
|
|
15
|
+
*
|
|
16
|
+
* 2. Pure compact — every letter as a single consonant char. After
|
|
17
|
+
* joinForAltCompare strips spaces this becomes the mushaf form
|
|
18
|
+
* itself, giving exact prefix matching for letter-by-letter input.
|
|
19
|
+
*
|
|
20
|
+
* 3. Full letter names — every letter spelled out (الف, ها, يا, etc.).
|
|
21
|
+
* Backstop for STT that delivers full phonetic forms.
|
|
22
|
+
*
|
|
23
|
+
* Single-letter muqatta'at (ص، ق، ن) only need two forms since compact
|
|
24
|
+
* and mushaf collapse to the same single character.
|
|
25
|
+
*/
|
|
26
|
+
export const wordAlternatives = [
|
|
27
|
+
// البقرة 2:1, آل عمران 3:1, العنكبوت 29:1, الروم 30:1, لقمان 31:1, السجدة 32:1
|
|
28
|
+
{
|
|
29
|
+
word: "الم",
|
|
30
|
+
alternatives: ["ا لام ميم", "ا ل م", "الف لام ميم"],
|
|
31
|
+
scope: { surah: 2, verse: 1 },
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
word: "الم",
|
|
35
|
+
alternatives: ["ا لام ميم", "ا ل م", "الف لام ميم"],
|
|
36
|
+
scope: { surah: 3, verse: 1 },
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
word: "الم",
|
|
40
|
+
alternatives: ["ا لام ميم", "ا ل م", "الف لام ميم"],
|
|
41
|
+
scope: { surah: 29, verse: 1 },
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
word: "الم",
|
|
45
|
+
alternatives: ["ا لام ميم", "ا ل م", "الف لام ميم"],
|
|
46
|
+
scope: { surah: 30, verse: 1 },
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
word: "الم",
|
|
50
|
+
alternatives: ["ا لام ميم", "ا ل م", "الف لام ميم"],
|
|
51
|
+
scope: { surah: 31, verse: 1 },
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
word: "الم",
|
|
55
|
+
alternatives: ["ا لام ميم", "ا ل م", "الف لام ميم"],
|
|
56
|
+
scope: { surah: 32, verse: 1 },
|
|
57
|
+
},
|
|
58
|
+
// الأعراف 7:1
|
|
59
|
+
{
|
|
60
|
+
word: "المص",
|
|
61
|
+
alternatives: ["ا لام ميم صاد", "ا ل م ص", "الف لام ميم صاد"],
|
|
62
|
+
scope: { surah: 7, verse: 1 },
|
|
63
|
+
},
|
|
64
|
+
// يونس 10:1, هود 11:1, يوسف 12:1, إبراهيم 14:1, الحجر 15:1
|
|
65
|
+
{
|
|
66
|
+
word: "الر",
|
|
67
|
+
alternatives: ["ا لام را", "ا ل ر", "الف لام را", "الف لام راء"],
|
|
68
|
+
scope: { surah: 10, verse: 1 },
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
word: "الر",
|
|
72
|
+
alternatives: ["ا لام را", "ا ل ر", "الف لام را", "الف لام راء"],
|
|
73
|
+
scope: { surah: 11, verse: 1 },
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
word: "الر",
|
|
77
|
+
alternatives: ["ا لام را", "ا ل ر", "الف لام را", "الف لام راء"],
|
|
78
|
+
scope: { surah: 12, verse: 1 },
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
word: "الر",
|
|
82
|
+
alternatives: ["ا لام را", "ا ل ر", "الف لام را", "الف لام راء"],
|
|
83
|
+
scope: { surah: 14, verse: 1 },
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
word: "الر",
|
|
87
|
+
alternatives: ["ا لام را", "ا ل ر", "الف لام را", "الف لام راء"],
|
|
88
|
+
scope: { surah: 15, verse: 1 },
|
|
89
|
+
},
|
|
90
|
+
// الرعد 13:1
|
|
91
|
+
{
|
|
92
|
+
word: "المر",
|
|
93
|
+
alternatives: [
|
|
94
|
+
"ا لام ميم را",
|
|
95
|
+
"ا ل م ر",
|
|
96
|
+
"الف لام ميم را",
|
|
97
|
+
"الف لام ميم راء",
|
|
98
|
+
],
|
|
99
|
+
scope: { surah: 13, verse: 1 },
|
|
100
|
+
},
|
|
101
|
+
// مريم 19:1
|
|
102
|
+
{
|
|
103
|
+
word: "كهيعص",
|
|
104
|
+
alternatives: [
|
|
105
|
+
"ك ه يا عين صاد",
|
|
106
|
+
"ك ه ي ع ص",
|
|
107
|
+
"كاف ها يا عين صاد",
|
|
108
|
+
"كاف هاء ياء عين صاد",
|
|
109
|
+
],
|
|
110
|
+
scope: { surah: 19, verse: 1 },
|
|
111
|
+
},
|
|
112
|
+
// طه 20:1
|
|
113
|
+
{
|
|
114
|
+
word: "طه",
|
|
115
|
+
alternatives: ["ط ها", "ط ه", "طا ها", "طا هاء"],
|
|
116
|
+
scope: { surah: 20, verse: 1 },
|
|
117
|
+
},
|
|
118
|
+
// الشعراء 26:1, القصص 28:1
|
|
119
|
+
{
|
|
120
|
+
word: "طسم",
|
|
121
|
+
alternatives: ["ط سين ميم", "ط س م", "طا سين ميم"],
|
|
122
|
+
scope: { surah: 26, verse: 1 },
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
word: "طسم",
|
|
126
|
+
alternatives: ["ط سين ميم", "ط س م", "طا سين ميم"],
|
|
127
|
+
scope: { surah: 28, verse: 1 },
|
|
128
|
+
},
|
|
129
|
+
// النمل 27:1
|
|
130
|
+
{
|
|
131
|
+
word: "طس",
|
|
132
|
+
alternatives: ["ط سين", "ط س", "طا سين"],
|
|
133
|
+
scope: { surah: 27, verse: 1 },
|
|
134
|
+
},
|
|
135
|
+
// يس 36:1 — realistic and full collapse to "يا سين"; ياسين covers the
|
|
136
|
+
// run-on form some reciters/STT produce.
|
|
137
|
+
{
|
|
138
|
+
word: "يس",
|
|
139
|
+
alternatives: ["يا سين", "ي س", "ياسين"],
|
|
140
|
+
scope: { surah: 36, verse: 1 },
|
|
141
|
+
},
|
|
142
|
+
// ص 38:1 — single letter; compact and mushaf form are identical.
|
|
143
|
+
{
|
|
144
|
+
word: "ص",
|
|
145
|
+
alternatives: ["صاد", "ص"],
|
|
146
|
+
scope: { surah: 38, verse: 1 },
|
|
147
|
+
},
|
|
148
|
+
// غافر 40:1, فصلت 41:1, الزخرف 43:1, الدخان 44:1, الجاثية 45:1, الأحقاف 46:1
|
|
149
|
+
{
|
|
150
|
+
word: "حم",
|
|
151
|
+
alternatives: ["ح ميم", "ح م", "حا ميم", "حاء ميم"],
|
|
152
|
+
scope: { surah: 40, verse: 1 },
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
word: "حم",
|
|
156
|
+
alternatives: ["ح ميم", "ح م", "حا ميم", "حاء ميم"],
|
|
157
|
+
scope: { surah: 41, verse: 1 },
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
word: "حم",
|
|
161
|
+
alternatives: ["ح ميم", "ح م", "حا ميم", "حاء ميم"],
|
|
162
|
+
scope: { surah: 43, verse: 1 },
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
word: "حم",
|
|
166
|
+
alternatives: ["ح ميم", "ح م", "حا ميم", "حاء ميم"],
|
|
167
|
+
scope: { surah: 44, verse: 1 },
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
word: "حم",
|
|
171
|
+
alternatives: ["ح ميم", "ح م", "حا ميم", "حاء ميم"],
|
|
172
|
+
scope: { surah: 45, verse: 1 },
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
word: "حم",
|
|
176
|
+
alternatives: ["ح ميم", "ح م", "حا ميم", "حاء ميم"],
|
|
177
|
+
scope: { surah: 46, verse: 1 },
|
|
178
|
+
},
|
|
179
|
+
// الشورى 42:1 = حم, 42:2 = عسق (split across two ayahs)
|
|
180
|
+
{
|
|
181
|
+
word: "حم",
|
|
182
|
+
alternatives: ["ح ميم", "ح م", "حا ميم", "حاء ميم"],
|
|
183
|
+
scope: { surah: 42, verse: 1 },
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
word: "عسق",
|
|
187
|
+
alternatives: ["عين سين قاف", "ع س ق"],
|
|
188
|
+
scope: { surah: 42, verse: 2 },
|
|
189
|
+
},
|
|
190
|
+
// ق 50:1 — single letter; compact and mushaf collapse.
|
|
191
|
+
{
|
|
192
|
+
word: "ق",
|
|
193
|
+
alternatives: ["قاف", "ق"],
|
|
194
|
+
scope: { surah: 50, verse: 1 },
|
|
195
|
+
},
|
|
196
|
+
// القلم 68:1 — single letter; compact and mushaf collapse.
|
|
197
|
+
{
|
|
198
|
+
word: "ن",
|
|
199
|
+
alternatives: ["نون", "ن"],
|
|
200
|
+
scope: { surah: 68, verse: 1 },
|
|
201
|
+
},
|
|
202
|
+
];
|
|
203
|
+
/**
|
|
204
|
+
* Look up alternatives for an expected word at a specific position.
|
|
205
|
+
* Returns alternatives only if a scoped entry matches the surah:verse,
|
|
206
|
+
* or if there's an unscoped entry (for future generic alternatives).
|
|
207
|
+
*/
|
|
208
|
+
export function getAlternativesFor(expected, surah, verse, alternatives = wordAlternatives) {
|
|
209
|
+
const normalized = normalizeArabic(expected);
|
|
210
|
+
const out = [];
|
|
211
|
+
for (const entry of alternatives) {
|
|
212
|
+
const wordMatches = entry.word === expected || entry.word === normalized;
|
|
213
|
+
if (!wordMatches)
|
|
214
|
+
continue;
|
|
215
|
+
// Unscoped entries always apply.
|
|
216
|
+
if (!entry.scope) {
|
|
217
|
+
out.push(...entry.alternatives);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
// Scoped entries only apply at their position.
|
|
221
|
+
if (entry.scope.surah === surah && entry.scope.verse === verse) {
|
|
222
|
+
out.push(...entry.alternatives);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
226
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sabiq/utils",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "A library of utils for use with Sabiq applications.",
|
|
5
|
+
"main": "./dist/cjs/index.js",
|
|
6
|
+
"module": "./dist/esm/index.js",
|
|
7
|
+
"types": "./dist/esm/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "rm -rf ./dist/ && yarn build:esm && yarn build:cjs",
|
|
13
|
+
"build:esm": "tsc",
|
|
14
|
+
"build:cjs": "tsc --module commonjs --outDir dist/cjs",
|
|
15
|
+
"lint": "eslint \"{**/*,*}.{js,ts,jsx,tsx}\"",
|
|
16
|
+
"prettier": "prettier --write \"{src,tests,example/src}/**/*.{js,ts,jsx,tsx}\""
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/Sabiq-Quran-App/SabiqUtils",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/Sabiq-Quran-App/SabiqUtils/issues"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"url": "https://github.com/Sabiq-Quran-App/SabiqUtils.git",
|
|
24
|
+
"type": "git"
|
|
25
|
+
},
|
|
26
|
+
"author": "Shady Abdelaziz",
|
|
27
|
+
"keywords": [
|
|
28
|
+
"sabiq",
|
|
29
|
+
"quran",
|
|
30
|
+
"react",
|
|
31
|
+
"mushaf",
|
|
32
|
+
"religion"
|
|
33
|
+
],
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"private": false,
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@babel/core": "^7.19.1",
|
|
41
|
+
"@types/react": "^18.0.18",
|
|
42
|
+
"@typescript-eslint/eslint-plugin": "^5.36.2",
|
|
43
|
+
"@typescript-eslint/parser": "^5.36.2",
|
|
44
|
+
"eslint": "^8.23.0",
|
|
45
|
+
"eslint-config-prettier": "^8.5.0",
|
|
46
|
+
"eslint-plugin-prettier": "^4.2.1",
|
|
47
|
+
"eslint-plugin-react": "^7.31.7",
|
|
48
|
+
"eslint-plugin-react-hooks": "^4.6.0",
|
|
49
|
+
"prettier": "^2.7.1",
|
|
50
|
+
"react": "^18.2.0",
|
|
51
|
+
"typescript": "^4.8.2"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"@sabiq/types": "^0.0.1",
|
|
55
|
+
"react": "^18.2.0"
|
|
56
|
+
},
|
|
57
|
+
"packageManager": "yarn@1.22.22"
|
|
58
|
+
}
|