tdk-api-wrapper 1.5.0 → 1.6.0

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/src/tdk.ts CHANGED
@@ -25,6 +25,229 @@ import * as os from "node:os";
25
25
  import * as https from "node:https";
26
26
  import * as tls from "node:tls";
27
27
 
28
+ /**
29
+ * Known frequent Turkish misspellings, erroneously joined compound words,
30
+ * and words where vowel dropping is prohibited by TDK (Yazım Kılavuzu).
31
+ */
32
+ export const COMMON_MISSPELLINGS: Record<string, string> = {
33
+ // -şey ile biten ve ayrı yazılması zorunlu söz öbekleri
34
+ herşey: "her şey",
35
+ hersey: "her şey",
36
+ birşey: "bir şey",
37
+ birsey: "bir şey",
38
+ hiçbirşey: "hiçbir şey",
39
+ hicbirsey: "hiçbir şey",
40
+ çokşey: "çok şey",
41
+ coksey: "çok şey",
42
+ şeyler: "şeyler",
43
+ seyler: "şeyler",
44
+ herhangibirşey: "herhangi bir şey",
45
+ herhangibirsey: "herhangi bir şey",
46
+
47
+ // Sıkça birleşik yazılan ama ayrı yazılması gereken sözler
48
+ hergün: "her gün",
49
+ hergun: "her gün",
50
+ herzaman: "her zaman",
51
+ heran: "her an",
52
+ heryer: "her yer",
53
+ herbiri: "her biri",
54
+ pekçok: "pek çok",
55
+ pekcok: "pek çok",
56
+ pekaz: "pek az",
57
+ yada: "ya da",
58
+ tabiki: "tabii ki",
59
+ tabiiki: "tabii ki",
60
+ sağol: "sağ ol",
61
+ sagol: "sağ ol",
62
+ sağolun: "sağ olun",
63
+ sagolun: "sağ olun",
64
+ hoşçakal: "hoşça kal",
65
+ hoscakal: "hoşça kal",
66
+ hoşgeldin: "hoş geldin",
67
+ hosgeldin: "hoş geldin",
68
+ hoşgeldiniz: "hoş geldiniz",
69
+ hosgeldiniz: "hoş geldiniz",
70
+ hoşbulduk: "hoş bulduk",
71
+ hosbulduk: "hoş bulduk",
72
+ yanısıra: "yanı sıra",
73
+ yanisira: "yanı sıra",
74
+ peşisıra: "peşi sıra",
75
+ pesisira: "peşi sıra",
76
+ ardısıra: "ardı sıra",
77
+ ardisira: "ardı sıra",
78
+ artarda: "art arda",
79
+ yüzyüze: "yüz yüze",
80
+ yuzyuze: "yüz yüze",
81
+ elele: "el ele",
82
+ gözgöze: "göz göze",
83
+ başbaşa: "baş başa",
84
+ basbasa: "baş başa",
85
+ yanyana: "yan yana",
86
+ içiçe: "iç içe",
87
+ icice: "iç içe",
88
+ üstüste: "üst üste",
89
+ ustuste: "üst üste",
90
+ altalta: "alt alta",
91
+ önsöz: "ön söz",
92
+ onsoz: "ön söz",
93
+ önyargı: "ön yargı",
94
+ onyargi: "ön yargı",
95
+ farketmek: "fark etmek",
96
+ farketti: "fark etti",
97
+ farkettim: "fark ettim",
98
+ farkeder: "fark eder",
99
+ farketmez: "fark etmez",
100
+ terketmek: "terk etmek",
101
+ terketti: "terk etti",
102
+ ayırdetmek: "ayırt etmek",
103
+ ayırtetmek: "ayırt etmek",
104
+ arzetmek: "arz etmek",
105
+ arzederim: "arz ederim",
106
+ varolmak: "var olmak",
107
+ yokolmak: "yok olmak",
108
+ haketmek: "hak etmek",
109
+ haketti: "hak etti",
110
+ hakkaten: "hakikaten",
111
+ hiçkimse: "hiç kimse",
112
+ hickimse: "hiç kimse",
113
+
114
+ // Ünlü düşmesi yapılmaması gereken yer bildiren sözler (TDK Kural 15)
115
+ burda: "burada",
116
+ burdan: "buradan",
117
+ şurda: "şurada",
118
+ surda: "şurada",
119
+ şurdan: "şuradan",
120
+ surdan: "şuradan",
121
+ orda: "orada",
122
+ ordan: "oradan",
123
+ içerde: "içeride",
124
+ icerde: "içeride",
125
+ içerden: "içeriden",
126
+ icerden: "içeriden",
127
+ dışarda: "dışarıda",
128
+ disarda: "dışarıda",
129
+ dışardan: "dışarıdan",
130
+ disardan: "dışarıdan",
131
+ yukarda: "yukarıda",
132
+ yukardan: "yukarıdan",
133
+
134
+ // Sıkça yanlış yazılan sözcükler
135
+ herkez: "herkes",
136
+ yanlız: "yalnız",
137
+ yalnış: "yanlış",
138
+ orjinal: "orijinal",
139
+ labaratuar: "laboratuvar",
140
+ laboratuar: "laboratuvar",
141
+ şöför: "şoför",
142
+ sofor: "şoför",
143
+ egzos: "egzoz",
144
+ eksoz: "egzoz",
145
+ ekzoz: "egzoz",
146
+ kiprik: "kirpik",
147
+ kirbit: "kibrit",
148
+ klavuz: "kılavuz",
149
+ kıravat: "kravat",
150
+ süpriz: "sürpriz",
151
+ supriz: "sürpriz",
152
+ raslantı: "rastlantı",
153
+ hastahane: "hastane",
154
+ pastahane: "pastane",
155
+ postahane: "postane",
156
+ eczahane: "eczane",
157
+ meyva: "meyve",
158
+ sarmısak: "sarımsak",
159
+ dinazor: "dinozor",
160
+ pantalon: "pantolon",
161
+ tesbih: "tespih",
162
+ ahçı: "aşçı",
163
+ matba: "matbaa",
164
+ idda: "iddia",
165
+ iddaa: "iddia",
166
+ muhattap: "muhatap",
167
+ traş: "tıraş",
168
+ karnıbahar: "karnabahar",
169
+ kareografi: "koreografi",
170
+ poaça: "poğaça",
171
+ pohaça: "poğaça",
172
+ şarz: "şarj",
173
+ sarj: "şarj",
174
+ makina: "makine",
175
+ müsade: "müsaade",
176
+ entellektüel: "entelektüel",
177
+ inisiyatif: "inisiyatif",
178
+ insiyatif: "inisiyatif",
179
+ sezeryan: "sezaryen",
180
+ doküman: "doküman",
181
+ döküman: "doküman",
182
+ erozyon: "erozyon",
183
+ erizyon: "erozyon",
184
+ anane: "anneanne",
185
+ babaanne: "babaanne",
186
+ };
187
+
188
+ export const SEY_EXCEPTIONS = new Set(["düşey", "eşey", "konsey", "jersey", "şey"]);
189
+
190
+ /**
191
+ * Turkish Q (QWERTY) keyboard geometry for the spell checker's closest-headword
192
+ * fallback. Plain Damerau-Levenshtein treats every wrong letter as one full
193
+ * edit, so it cannot tell that "arabs" is much more likely a slip for "araba"
194
+ * (s and a sit next to each other) than for some equidistant headword, or that
195
+ * "swlam" is "selam" (w next to e). These tables let a substitution cost a
196
+ * fraction of an edit when the two keys are physically adjacent — or are the
197
+ * ASCII/diacritic pair of one another (ı/i, ş/s, ö/o, …), the other dominant
198
+ * class of Turkish typo — so the nearest *and* most plausible headword wins.
199
+ * Everyone is assumed to be on a Turkish Q layout.
200
+ */
201
+ const KEYBOARD_ROWS: ReadonlyArray<readonly [string, number]> = [
202
+ ["qwertyuıopğü", 0],
203
+ ["asdfghjklşi", 0.5],
204
+ ["zxcvbnmöç", 1],
205
+ ];
206
+ const KEYBOARD_COORDS: Readonly<Record<string, readonly [number, number]>> = (() => {
207
+ const coords: Record<string, readonly [number, number]> = {};
208
+ KEYBOARD_ROWS.forEach(([keys, offset], row) => {
209
+ [...keys].forEach((key, col) => {
210
+ coords[key] = [col + offset, row];
211
+ });
212
+ });
213
+ return coords;
214
+ })();
215
+
216
+ /** ASCII <-> Turkish-diacritic siblings, treated as an almost-free substitution. */
217
+ const DIACRITIC_SIBLINGS: Readonly<Record<string, string>> = {
218
+ ı: "i", i: "ı", ö: "o", o: "ö", ü: "u", u: "ü",
219
+ ş: "s", s: "ş", ç: "c", c: "ç", ğ: "g", g: "ğ", â: "a", a: "â",
220
+ };
221
+
222
+ /** Cost of substituting a key for its left/right neighbour on the same row. */
223
+ const KEYBOARD_ROW_SUB_COST = 0.4;
224
+ /** Cost of substituting a key for a diagonally adjacent one on the row above/below. */
225
+ const KEYBOARD_DIAGONAL_SUB_COST = 0.55;
226
+ /** Cost of confusing a letter with its diacritic/ASCII sibling. */
227
+ const DIACRITIC_SUB_COST = 0.3;
228
+ /** Cost of a transposition ("selam" <-> "selma"): a single wrong finger order. */
229
+ const TRANSPOSITION_COST = 0.8;
230
+
231
+ /**
232
+ * Weighted substitution cost between two single characters: 0 if identical,
233
+ * a small fraction if they are diacritic siblings or neighbouring keys on a
234
+ * Turkish Q keyboard, otherwise a full 1.
235
+ */
236
+ function keyboardSubCost(a: string, b: string): number {
237
+ if (a === b) return 0;
238
+ if (DIACRITIC_SIBLINGS[a] === b) return DIACRITIC_SUB_COST;
239
+ const pa = KEYBOARD_COORDS[a];
240
+ const pb = KEYBOARD_COORDS[b];
241
+ if (!pa || !pb) return 1;
242
+ const dx = Math.abs(pa[0] - pb[0]);
243
+ const dy = Math.abs(pa[1] - pb[1]);
244
+ // Same row, immediate horizontal neighbour: the most common slip.
245
+ if (dy === 0 && dx <= 1 + 1e-9) return KEYBOARD_ROW_SUB_COST;
246
+ // One row up/down and within roughly one key horizontally: a diagonal slip.
247
+ if (dy === 1 && dx <= 1 + 1e-9) return KEYBOARD_DIAGONAL_SUB_COST;
248
+ return 1;
249
+ }
250
+
28
251
  /**
29
252
  * TDK (Türk Dil Kurumu) API Wrapper
30
253
  */
@@ -630,32 +853,56 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
630
853
  * Checks spelling and returns suggestions if wrong.
631
854
  */
632
855
  public static async checkSpelling(word: string): Promise<SpellCheckResult> {
633
- // 1. Check if word exists
856
+ if (!word || word.trim() === "") {
857
+ return { isCorrect: false, word };
858
+ }
859
+
860
+ const cleanWord = word.trim().toLocaleLowerCase("tr-TR");
861
+
862
+ // 1. Check if word exists in TDK dictionary
634
863
  const results = await this.getWord(word);
635
864
  if (results.length > 0) {
636
865
  return { isCorrect: true, word };
637
866
  }
638
867
 
639
- // 2. If not, check "sıkça yapılan yanlışlar" from DailyContent — an exact
640
- // match here is TDK explicitly saying "X is often confused with Y", so
641
- // it's authoritative when it hits (but only 2-3 rotating entries per call).
868
+ // 2. Common Turkish misspellings, erroneously joined compounds, and vowel drop errors
869
+ if (COMMON_MISSPELLINGS[cleanWord]) {
870
+ return { isCorrect: false, word, suggestion: COMMON_MISSPELLINGS[cleanWord] };
871
+ }
872
+
873
+ // 3. Dynamic -şey / -sey attached check:
874
+ // In Turkish, 'şey' is an indefinite pronoun and is ALWAYS written separately from the preceding word
875
+ // (e.g. her şey, bir şey, hiçbir şey, çok şey, her şeyi, bir şeyler).
876
+ const seyMatch = cleanWord.match(/^(.+?)(?:şey|sey)([ıiuaeüodekmnl]+)?$/);
877
+ if (seyMatch && !SEY_EXCEPTIONS.has(cleanWord)) {
878
+ let prefix = seyMatch[1];
879
+ const suffix = seyMatch[2] || "";
880
+ if (prefix === "hicbir") prefix = "hiçbir";
881
+ if (prefix === "cok") prefix = "çok";
882
+ return {
883
+ isCorrect: false,
884
+ word,
885
+ suggestion: `${prefix} şey${suffix}`,
886
+ };
887
+ }
888
+
889
+ // 4. "Sıkça yapılan yanlışlar" from DailyContent
642
890
  const daily = await this.getDailyContent();
643
891
  if (daily) {
644
- const syydMatch = daily.syyd.find(s => s.yanliskelime.toLocaleLowerCase("tr-TR") === word.toLocaleLowerCase("tr-TR"));
892
+ const syydMatch = daily.syyd.find((s) => s.yanliskelime.toLocaleLowerCase("tr-TR") === cleanWord);
645
893
  if (syydMatch) {
646
894
  return { isCorrect: false, word, suggestion: syydMatch.dogrukelime };
647
895
  }
648
- const mixMatch = daily.karistirma.find(s => s.yanlis.toLocaleLowerCase("tr-TR") === word.toLocaleLowerCase("tr-TR"));
896
+ const mixMatch = daily.karistirma.find((s) => s.yanlis.toLocaleLowerCase("tr-TR") === cleanWord);
649
897
  if (mixMatch) {
650
898
  return { isCorrect: false, word, suggestion: mixMatch.dogru };
651
899
  }
652
900
  }
653
901
 
654
- // 3. Morphology Fallback: Check if the word is an inflected form of a known headword
655
- // (e.g., "halılarımızın" -> "halı", "kitabımız" -> "kitap", "çocuğa" -> "çocuk")
902
+ // 5. Morphology Fallback: Check if the word is an inflected form or bare verb imperative of a known headword
903
+ // (e.g., "halılarımızın" -> "halı", "kitabımız" -> "kitap", "çocuğa" -> "çocuk", "söyle" -> "söylemek")
656
904
  const root = await this.findRoot(word);
657
905
  if (root) {
658
- const cleanWord = word.trim().toLocaleLowerCase("tr-TR");
659
906
  const isInflected = root !== cleanWord;
660
907
  return {
661
908
  isCorrect: true,
@@ -665,42 +912,52 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
665
912
  };
666
913
  }
667
914
 
668
- // 4. No exact match or morphology root: fall back to the closest
669
- // headword (by edit distance) across TDK's full ~81k-word list (the same
670
- // data `getSuggestions()` uses). Restricted to single-token, lowercase
671
- // headwords so it doesn't suggest compounds/phrases or proper nouns.
672
- // Candidates whose length differs too much are skipped before running
673
- // the O(n*m) distance calculation, both for speed and because a huge
674
- // length gap can't be within the distance threshold anyway. Ties (same
675
- // distance) prefer a matching first letter, then a matching length —
676
- // typos rarely change the first letter, and this avoids picking
677
- // whatever happens to sort alphabetically first. There's no word
678
- // frequency data available, so a genuine tie can still land on a
679
- // technically-correct but less commonly intended word.
915
+ // 6. Check if headwords with spaces match when space is removed (e.g. "ön yargı" for "önyargı")
680
916
  if (this.autocompleteCache.length === 0) {
681
917
  this.autocompleteCache = await this.fetchAutocompleteData();
682
918
  }
683
- const cleanWord = word.trim().toLocaleLowerCase("tr-TR");
684
- let best: { candidate: string; distance: number; firstMismatch: number; lengthMismatch: number } | null = null;
919
+ for (const candidate of this.autocompleteCache) {
920
+ if (candidate.includes(" ")) {
921
+ const candidateNoSpace = candidate.replace(/\s+/g, "").toLocaleLowerCase("tr-TR");
922
+ if (candidateNoSpace === cleanWord) {
923
+ return { isCorrect: false, word, suggestion: candidate };
924
+ }
925
+ }
926
+ }
927
+
928
+ // 7. No exact match or morphology root: fall back to closest headword by edit distance.
929
+ // Candidates are ranked by the keyboard-/diacritic-aware distance (so "arabs" picks
930
+ // "araba" over an equidistant headword because s->a is a neighbouring-key slip, and
931
+ // "swlam" picks "selam" because w->e is), while the plain integer Damerau-Levenshtein
932
+ // still gates acceptance. Ties prefer matching first letter, then matching length, and
933
+ // initial character mismatches are penalized so irrelevant foreign loanwords (like
934
+ // 'jersey') do not beat Turkish roots.
935
+ let best:
936
+ | { candidate: string; score: number; rawDist: number; firstMismatch: number; lengthMismatch: number }
937
+ | null = null;
685
938
  for (const candidate of this.autocompleteCache) {
686
939
  if (candidate.includes(" ") || candidate !== candidate.toLocaleLowerCase("tr-TR")) continue;
687
940
  if (Math.abs(candidate.length - cleanWord.length) > 2) continue;
688
941
 
689
- const distance = this.damerauLevenshtein(cleanWord, candidate);
690
- if (distance === 0) continue;
942
+ const rawDist = this.damerauLevenshtein(cleanWord, candidate);
943
+ if (rawDist === 0 || rawDist > 2) continue;
691
944
 
692
945
  const firstMismatch = candidate[0] === cleanWord[0] ? 0 : 1;
693
946
  const lengthMismatch = candidate.length === cleanWord.length ? 0 : 1;
947
+ const score = this.keyboardAwareDistance(cleanWord, candidate) + (firstMismatch > 0 ? 1.2 : 0);
948
+
694
949
  const better =
695
950
  !best ||
696
- distance < best.distance ||
697
- (distance === best.distance && firstMismatch < best.firstMismatch) ||
698
- (distance === best.distance && firstMismatch === best.firstMismatch && lengthMismatch < best.lengthMismatch);
951
+ score < best.score - 1e-9 ||
952
+ (Math.abs(score - best.score) < 1e-9 && firstMismatch < best.firstMismatch) ||
953
+ (Math.abs(score - best.score) < 1e-9 &&
954
+ firstMismatch === best.firstMismatch &&
955
+ lengthMismatch < best.lengthMismatch);
699
956
  if (better) {
700
- best = { candidate, distance, firstMismatch, lengthMismatch };
957
+ best = { candidate, score, rawDist, firstMismatch, lengthMismatch };
701
958
  }
702
959
  }
703
- if (best && best.distance <= 2) {
960
+ if (best && best.rawDist <= 2 && (best.firstMismatch === 0 || best.rawDist <= 1)) {
704
961
  return { isCorrect: false, word, suggestion: best.candidate };
705
962
  }
706
963
  return { isCorrect: false, word };
@@ -1231,6 +1488,31 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
1231
1488
  return dp[a.length][b.length];
1232
1489
  }
1233
1490
 
1491
+ /**
1492
+ * Keyboard- and diacritic-aware edit distance: same optimal-string-alignment
1493
+ * recurrence as {@link damerauLevenshtein}, but a substitution is charged by
1494
+ * {@link keyboardSubCost} (a fraction of an edit when the two letters are
1495
+ * adjacent on a Turkish Q keyboard or are ASCII/diacritic siblings) and a
1496
+ * transposition costs {@link TRANSPOSITION_COST}. Insertions and deletions
1497
+ * still cost a full 1. Used only to *rank* spelling candidates; the plain
1498
+ * integer distance still gates whether a suggestion is offered at all.
1499
+ */
1500
+ private static keyboardAwareDistance(a: string, b: string): number {
1501
+ const dp: number[][] = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
1502
+ for (let i = 0; i <= a.length; i++) dp[i][0] = i;
1503
+ for (let j = 0; j <= b.length; j++) dp[0][j] = j;
1504
+ for (let i = 1; i <= a.length; i++) {
1505
+ for (let j = 1; j <= b.length; j++) {
1506
+ const cost = keyboardSubCost(a[i - 1], b[j - 1]);
1507
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
1508
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
1509
+ dp[i][j] = Math.min(dp[i][j], dp[i - 2][j - 2] + TRANSPOSITION_COST);
1510
+ }
1511
+ }
1512
+ }
1513
+ return dp[a.length][b.length];
1514
+ }
1515
+
1234
1516
  /**
1235
1517
  * Fetches multiple words concurrently with a small delay to avoid rate limiting.
1236
1518
  */
@@ -1477,6 +1759,81 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
1477
1759
  "sanki", "oysaki", "mademki", "belki", "halbuki", "çünkü", "meğerki", "illaki"
1478
1760
  ]);
1479
1761
 
1762
+ // 1. Detect multi-word phrases that should be written as single compound words
1763
+ const PHRASE_MISTAKES: {
1764
+ regex: RegExp;
1765
+ suggestion: string;
1766
+ message: string;
1767
+ type: ProofreadIssue["type"];
1768
+ }[] = [
1769
+ {
1770
+ regex: /\bhiç\s+bir\b/gi,
1771
+ suggestion: "hiçbir",
1772
+ message: "'hiçbir' belgisiz sıfatı bitişik yazılmalıdır.",
1773
+ type: "spelling",
1774
+ },
1775
+ {
1776
+ regex: /\bbir\s+çok\b/gi,
1777
+ suggestion: "birçok",
1778
+ message: "'birçok' belgisiz sıfatı/zamiri bitişik yazılmalıdır.",
1779
+ type: "spelling",
1780
+ },
1781
+ {
1782
+ regex: /\bbir\s+kaç\b/gi,
1783
+ suggestion: "birkaç",
1784
+ message: "'birkaç' belgisiz sıfatı/zamiri bitişik yazılmalıdır.",
1785
+ type: "spelling",
1786
+ },
1787
+ {
1788
+ regex: /\bbir\s+az\b/gi,
1789
+ suggestion: "biraz",
1790
+ message: "'biraz' sözcüğü bitişik yazılmalıdır.",
1791
+ type: "spelling",
1792
+ },
1793
+ {
1794
+ regex: /\bher\s+hangi\b/gi,
1795
+ suggestion: "herhangi",
1796
+ message: "'herhangi' sözcüğü bitişik yazılmalıdır.",
1797
+ type: "spelling",
1798
+ },
1799
+ {
1800
+ regex: /\bgit\s+gide\b/gi,
1801
+ suggestion: "gitgide",
1802
+ message: "'gitgide' zarfı bitişik yazılmalıdır.",
1803
+ type: "spelling",
1804
+ },
1805
+ {
1806
+ regex: /\bbirden\s+bire\b/gi,
1807
+ suggestion: "birdenbire",
1808
+ message: "'birdenbire' zarfı bitişik yazılmalıdır.",
1809
+ type: "spelling",
1810
+ },
1811
+ {
1812
+ regex: /\brast\s+gele\b/gi,
1813
+ suggestion: "rastgele",
1814
+ message: "'rastgele' zarfı bitişik yazılmalıdır.",
1815
+ type: "spelling",
1816
+ },
1817
+ ];
1818
+
1819
+ const coveredRanges: { start: number; end: number }[] = [];
1820
+ for (const pm of PHRASE_MISTAKES) {
1821
+ let pmMatch: RegExpExecArray | null;
1822
+ while ((pmMatch = pm.regex.exec(text)) !== null) {
1823
+ const start = pmMatch.index;
1824
+ const end = start + pmMatch[0].length;
1825
+ coveredRanges.push({ start, end });
1826
+ issues.push({
1827
+ type: pm.type,
1828
+ word: pmMatch[0],
1829
+ startIndex: start,
1830
+ endIndex: end,
1831
+ suggestion: pm.suggestion,
1832
+ message: pm.message,
1833
+ });
1834
+ }
1835
+ }
1836
+
1480
1837
  const tokenRegex = /[\p{L}0-9'’]+/gu;
1481
1838
  let match: RegExpExecArray | null;
1482
1839
 
@@ -1487,6 +1844,7 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
1487
1844
  const lower = rawWord.toLocaleLowerCase("tr-TR");
1488
1845
 
1489
1846
  if (/^\d+$/.test(lower)) continue;
1847
+ if (coveredRanges.some((r) => startIndex >= r.start && endIndex <= r.end)) continue;
1490
1848
 
1491
1849
  let flagged = false;
1492
1850
 
@@ -1520,12 +1878,8 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
1520
1878
  if (!(await this.isHeadword(lower))) {
1521
1879
  const root = await this.findRoot(base);
1522
1880
  const isVerb =
1523
- (root && (root.endsWith("mek") || root.endsWith("mak"))) ||
1524
- base === "demek" ||
1525
- base === "kaldı" ||
1526
- base === "yeter" ||
1527
- base === "bilmem" ||
1528
- VERB_CONJUGATION_REGEX.test(base);
1881
+ (base === "demek" || base === "kaldı" || base === "yeter" || base === "bilmem" || VERB_CONJUGATION_REGEX.test(base)) &&
1882
+ (root ? root.endsWith("mek") || root.endsWith("mak") : true);
1529
1883
 
1530
1884
  if (isVerb) {
1531
1885
  issues.push({
@@ -1549,8 +1903,8 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
1549
1903
  if (!(await this.isHeadword(lower))) {
1550
1904
  const root = await this.findRoot(base);
1551
1905
  const isVerb =
1552
- (root && (root.endsWith("mek") || root.endsWith("mak"))) ||
1553
- VERB_CONJUGATION_REGEX.test(base);
1906
+ VERB_CONJUGATION_REGEX.test(base) &&
1907
+ (root ? root.endsWith("mek") || root.endsWith("mak") : false);
1554
1908
 
1555
1909
  if (isVerb) {
1556
1910
  const correctEnding = ending.startsWith("t") ? (ending === "te" ? "de" : "da") : ending;
@@ -1567,7 +1921,52 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
1567
1921
  }
1568
1922
  }
1569
1923
 
1570
- // 4. General Spell Check
1924
+ // 4. Check -şey / -sey erroneously attached to preceding word
1925
+ const seyMatch = lower.match(/^(.+?)(?:şey|sey)([ıiuaeüodekmnl]+)?$/);
1926
+ if (!flagged && seyMatch && !SEY_EXCEPTIONS.has(lower)) {
1927
+ let prefix = seyMatch[1];
1928
+ const suffix = seyMatch[2] || "";
1929
+ if (prefix === "hicbir") prefix = "hiçbir";
1930
+ if (prefix === "cok") prefix = "çok";
1931
+ issues.push({
1932
+ type: "spelling",
1933
+ word: rawWord,
1934
+ startIndex,
1935
+ endIndex,
1936
+ suggestion: `${prefix} şey${suffix}`,
1937
+ message: "'şey' sözcüğü kendinden önceki kelimeden ayrı yazılmalıdır.",
1938
+ });
1939
+ flagged = true;
1940
+ }
1941
+
1942
+ // 5. Check 'yada' conjunction mistake
1943
+ if (!flagged && lower === "yada") {
1944
+ issues.push({
1945
+ type: "spelling",
1946
+ word: rawWord,
1947
+ startIndex,
1948
+ endIndex,
1949
+ suggestion: "ya da",
1950
+ message: "'ya da' bağlacı her zaman ayrı yazılır.",
1951
+ });
1952
+ flagged = true;
1953
+ }
1954
+
1955
+ // 6. Check common vowel drop mistakes: burda, şurda, orda, vb. (TDK Kural 15)
1956
+ if (!flagged && (lower === "burda" || lower === "şurda" || lower === "surda" || lower === "orda" || lower === "içerde" || lower === "icerde" || lower === "dışarda" || lower === "disarda" || lower === "yukarda")) {
1957
+ const correct = COMMON_MISSPELLINGS[lower] || lower;
1958
+ issues.push({
1959
+ type: "spelling",
1960
+ word: rawWord,
1961
+ startIndex,
1962
+ endIndex,
1963
+ suggestion: correct,
1964
+ message: `'${rawWord}' sözcüğünde ünlü düşmesi yapılmaz.`,
1965
+ });
1966
+ flagged = true;
1967
+ }
1968
+
1969
+ // 7. General Spell Check
1571
1970
  if (!flagged) {
1572
1971
  const check = await this.checkSpelling(rawWord);
1573
1972
  if (!check.isCorrect) {
@@ -1578,13 +1977,15 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
1578
1977
  endIndex,
1579
1978
  suggestion: check.suggestion,
1580
1979
  message: check.suggestion
1581
- ? `'${rawWord}' yanlış yazılmış olabilir. Öneri: '${check.suggestion}'`
1980
+ ? `'${rawWord}' yanlış yazılmış olabilir.`
1582
1981
  : `'${rawWord}' sözlükte bulunamadı.`,
1583
1982
  });
1584
1983
  }
1585
1984
  }
1586
1985
  }
1587
1986
 
1987
+ issues.sort((a, b) => a.startIndex - b.startIndex);
1988
+
1588
1989
  return {
1589
1990
  text,
1590
1991
  issues,
@@ -118,6 +118,17 @@ async function runTests() {
118
118
 
119
119
  const sp4 = await TDK.checkSpelling("asdfxyz12345");
120
120
  assert.strictEqual(sp4.isCorrect, false);
121
+
122
+ // Keyboard-aware ranking: "swlam" -> "selam" because w and e are horizontal
123
+ // neighbours on a Turkish Q keyboard (w->a is only a diagonal slip, so "salam" loses).
124
+ const sp5 = await TDK.checkSpelling("swlam");
125
+ assert.strictEqual(sp5.isCorrect, false);
126
+ assert.strictEqual(sp5.suggestion, "selam");
127
+
128
+ // Neighbouring-key substitution beats an equidistant headword: s and a are adjacent.
129
+ const sp6 = await TDK.checkSpelling("arabs");
130
+ assert.strictEqual(sp6.isCorrect, false);
131
+ assert.strictEqual(sp6.suggestion, "araba");
121
132
  console.log(" ✓ TDK.checkSpelling passed.");
122
133
 
123
134
  console.log("\n All morphology tests passed successfully!");
@@ -51,6 +51,34 @@ async function runTests() {
51
51
  assert.strictEqual(combo.issues.some((i) => i.type === "question_particle"), true);
52
52
  console.log(" ✓ Combined proofread passed.");
53
53
 
54
+ // 5. Şey detachment check (herşey, hersey, birşeyler -> her şey, bir şeyler)
55
+ console.log("5. Testing -şey detachment...");
56
+ const s1 = await TDK.proofread("burda herşey yolunda");
57
+ assert.strictEqual(s1.isCorrect, false);
58
+ assert.strictEqual(s1.issues.length, 2);
59
+ assert.strictEqual(s1.issues[0].word, "burda");
60
+ assert.strictEqual(s1.issues[0].suggestion, "burada");
61
+ assert.strictEqual(s1.issues[1].word, "herşey");
62
+ assert.strictEqual(s1.issues[1].suggestion, "her şey");
63
+
64
+ const s2 = await TDK.proofread("hersey çok güzel");
65
+ assert.strictEqual(s2.isCorrect, false);
66
+ assert.strictEqual(s2.issues[0].word, "hersey");
67
+ assert.strictEqual(s2.issues[0].suggestion, "her şey");
68
+ console.log(" ✓ -şey detachment passed.");
69
+
70
+ // 6. Erroneously separated compound phrases (hiç bir, bir çok, git gide)
71
+ console.log("6. Testing compound phrases...");
72
+ const p1 = await TDK.proofread("hiç bir şey bilmiyor");
73
+ assert.strictEqual(p1.isCorrect, false);
74
+ assert.strictEqual(p1.issues[0].word, "hiç bir");
75
+ assert.strictEqual(p1.issues[0].suggestion, "hiçbir");
76
+
77
+ const p2 = await TDK.proofread("yada gelme");
78
+ assert.strictEqual(p2.isCorrect, false);
79
+ assert.strictEqual(p2.issues[0].suggestion, "ya da");
80
+ console.log(" ✓ Compound phrases passed.");
81
+
54
82
  console.log("\n All proofread tests passed successfully!");
55
83
  }
56
84