tdk-api-wrapper 1.4.0 → 1.5.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/src/tdk.ts CHANGED
@@ -10,6 +10,12 @@ import type {
10
10
  TDKRule,
11
11
  KubbealtiEntry,
12
12
  WiktionaryEntry,
13
+ ProofreadIssue,
14
+ ProofreadResult,
15
+ PatternSearchOptions,
16
+ AnagramOptions,
17
+ RhymeOptions,
18
+ TDKConfig,
13
19
  } from "./types";
14
20
  import { TDKValidationError, TDKNetworkError } from "./errors";
15
21
  import { getStemCandidates } from "./morphology";
@@ -19,6 +25,168 @@ import * as os from "node:os";
19
25
  import * as https from "node:https";
20
26
  import * as tls from "node:tls";
21
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
+
22
190
  /**
23
191
  * TDK (Türk Dil Kurumu) API Wrapper
24
192
  */
@@ -104,6 +272,11 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
104
272
  -----END CERTIFICATE-----`,
105
273
  ];
106
274
 
275
+ // Configuration
276
+ private static defaultTimeoutMs = 8000;
277
+ private static defaultRetries = 1;
278
+ private static maxCacheSize = 1000;
279
+
107
280
  // Cache Mechanism
108
281
  private static isCacheEnabled = false;
109
282
  private static wordCache = new Map<string, WordInfo[]>();
@@ -112,6 +285,16 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
112
285
  private static autocompleteSet: Set<string> = new Set<string>();
113
286
  private static stemCache = new Map<string, string | null>();
114
287
 
288
+ /**
289
+ * Configures global client options such as network timeout, retries, and cache size.
290
+ */
291
+ public static configure(config: TDKConfig): void {
292
+ if (config.timeoutMs !== undefined) this.defaultTimeoutMs = Math.max(100, config.timeoutMs);
293
+ if (config.retries !== undefined) this.defaultRetries = Math.max(0, config.retries);
294
+ if (config.cache !== undefined) this.enableCache(config.cache);
295
+ if (config.maxCacheSize !== undefined) this.maxCacheSize = Math.max(10, config.maxCacheSize);
296
+ }
297
+
115
298
  /**
116
299
  * Enables or disables in-memory caching for API requests.
117
300
  */
@@ -133,10 +316,58 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
133
316
  this.stemCache.clear();
134
317
  }
135
318
 
319
+ private static setBoundedCache<K, V>(map: Map<K, V>, key: K, value: V): void {
320
+ if (map.size >= this.maxCacheSize) {
321
+ const firstKey = map.keys().next().value;
322
+ if (firstKey !== undefined) map.delete(firstKey);
323
+ }
324
+ map.set(key, value);
325
+ }
326
+
136
327
  private static delay(ms: number) {
137
328
  return new Promise((resolve) => setTimeout(resolve, ms));
138
329
  }
139
330
 
331
+ /**
332
+ * Internal helper that performs HTTP fetch with timeout and automatic retry on network/5xx errors.
333
+ */
334
+ private static async fetchWithRetry(
335
+ url: string,
336
+ options: RequestInit = {},
337
+ retries: number = this.defaultRetries,
338
+ timeoutMs: number = this.defaultTimeoutMs
339
+ ): Promise<Response> {
340
+ let lastError: unknown;
341
+ for (let attempt = 0; attempt <= retries; attempt++) {
342
+ try {
343
+ const signal = AbortSignal.timeout(timeoutMs);
344
+ const headers = {
345
+ "User-Agent": "TDK-API-Nodejs-Wrapper/1.0",
346
+ ...((options.headers as Record<string, string>) || {}),
347
+ };
348
+ const res = await fetch(url, { ...options, headers, signal });
349
+ if (res.ok || (res.status >= 400 && res.status < 500)) {
350
+ return res;
351
+ }
352
+ // If 5xx server error, retry
353
+ if (attempt < retries) {
354
+ await this.delay(200 * (attempt + 1));
355
+ continue;
356
+ }
357
+ return res;
358
+ } catch (err) {
359
+ lastError = err;
360
+ if (attempt < retries) {
361
+ await this.delay(200 * (attempt + 1));
362
+ continue;
363
+ }
364
+ }
365
+ }
366
+ throw new TDKNetworkError(`Request to ${url} failed after ${retries + 1} attempts.`, {
367
+ cause: lastError,
368
+ });
369
+ }
370
+
140
371
  /**
141
372
  * Fetches detailed information for a given word from the TDK Dictionary.
142
373
  */
@@ -155,9 +386,7 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
155
386
 
156
387
  let response: Response;
157
388
  try {
158
- response = await fetch(url, {
159
- headers: { "User-Agent": "TDK-API-Nodejs-Wrapper/1.0" },
160
- });
389
+ response = await this.fetchWithRetry(url);
161
390
  } catch (error) {
162
391
  throw new TDKNetworkError("Failed to fetch word from TDK: request failed.", { cause: error });
163
392
  }
@@ -176,13 +405,13 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
176
405
  }
177
406
 
178
407
  if (!Array.isArray(data) && data && "error" in (data as Record<string, unknown>)) {
179
- if (this.isCacheEnabled) this.wordCache.set(cleanWord, []);
408
+ if (this.isCacheEnabled) this.setBoundedCache(this.wordCache, cleanWord, []);
180
409
  return [];
181
410
  }
182
411
 
183
412
  const results = data as WordInfo[];
184
413
  if (this.isCacheEnabled) {
185
- this.wordCache.set(cleanWord, results);
414
+ this.setBoundedCache(this.wordCache, cleanWord, results);
186
415
  }
187
416
  return results;
188
417
  }
@@ -320,7 +549,7 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
320
549
 
321
550
  // 1. If the word itself is an exact headword, it is its own root
322
551
  if (await this.isHeadword(clean)) {
323
- this.stemCache.set(clean, clean);
552
+ this.setBoundedCache(this.stemCache, clean, clean);
324
553
  return clean;
325
554
  }
326
555
 
@@ -328,12 +557,12 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
328
557
  const candidates = getStemCandidates(clean);
329
558
  for (const candidate of candidates) {
330
559
  if (await this.isHeadword(candidate)) {
331
- this.stemCache.set(clean, candidate);
560
+ this.setBoundedCache(this.stemCache, clean, candidate);
332
561
  return candidate;
333
562
  }
334
563
  }
335
564
 
336
- this.stemCache.set(clean, null);
565
+ this.setBoundedCache(this.stemCache, clean, null);
337
566
  return null;
338
567
  }
339
568
 
@@ -563,32 +792,56 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
563
792
  * Checks spelling and returns suggestions if wrong.
564
793
  */
565
794
  public static async checkSpelling(word: string): Promise<SpellCheckResult> {
566
- // 1. Check if word exists
795
+ if (!word || word.trim() === "") {
796
+ return { isCorrect: false, word };
797
+ }
798
+
799
+ const cleanWord = word.trim().toLocaleLowerCase("tr-TR");
800
+
801
+ // 1. Check if word exists in TDK dictionary
567
802
  const results = await this.getWord(word);
568
803
  if (results.length > 0) {
569
804
  return { isCorrect: true, word };
570
805
  }
571
806
 
572
- // 2. If not, check "sıkça yapılan yanlışlar" from DailyContent — an exact
573
- // match here is TDK explicitly saying "X is often confused with Y", so
574
- // it's authoritative when it hits (but only 2-3 rotating entries per call).
807
+ // 2. Common Turkish misspellings, erroneously joined compounds, and vowel drop errors
808
+ if (COMMON_MISSPELLINGS[cleanWord]) {
809
+ return { isCorrect: false, word, suggestion: COMMON_MISSPELLINGS[cleanWord] };
810
+ }
811
+
812
+ // 3. Dynamic -şey / -sey attached check:
813
+ // In Turkish, 'şey' is an indefinite pronoun and is ALWAYS written separately from the preceding word
814
+ // (e.g. her şey, bir şey, hiçbir şey, çok şey, her şeyi, bir şeyler).
815
+ const seyMatch = cleanWord.match(/^(.+?)(?:şey|sey)([ıiuaeüodekmnl]+)?$/);
816
+ if (seyMatch && !SEY_EXCEPTIONS.has(cleanWord)) {
817
+ let prefix = seyMatch[1];
818
+ const suffix = seyMatch[2] || "";
819
+ if (prefix === "hicbir") prefix = "hiçbir";
820
+ if (prefix === "cok") prefix = "çok";
821
+ return {
822
+ isCorrect: false,
823
+ word,
824
+ suggestion: `${prefix} şey${suffix}`,
825
+ };
826
+ }
827
+
828
+ // 4. "Sıkça yapılan yanlışlar" from DailyContent
575
829
  const daily = await this.getDailyContent();
576
830
  if (daily) {
577
- const syydMatch = daily.syyd.find(s => s.yanliskelime.toLocaleLowerCase("tr-TR") === word.toLocaleLowerCase("tr-TR"));
831
+ const syydMatch = daily.syyd.find((s) => s.yanliskelime.toLocaleLowerCase("tr-TR") === cleanWord);
578
832
  if (syydMatch) {
579
833
  return { isCorrect: false, word, suggestion: syydMatch.dogrukelime };
580
834
  }
581
- const mixMatch = daily.karistirma.find(s => s.yanlis.toLocaleLowerCase("tr-TR") === word.toLocaleLowerCase("tr-TR"));
835
+ const mixMatch = daily.karistirma.find((s) => s.yanlis.toLocaleLowerCase("tr-TR") === cleanWord);
582
836
  if (mixMatch) {
583
837
  return { isCorrect: false, word, suggestion: mixMatch.dogru };
584
838
  }
585
839
  }
586
840
 
587
- // 3. Morphology Fallback: Check if the word is an inflected form of a known headword
588
- // (e.g., "halılarımızın" -> "halı", "kitabımız" -> "kitap", "çocuğa" -> "çocuk")
841
+ // 5. Morphology Fallback: Check if the word is an inflected form or bare verb imperative of a known headword
842
+ // (e.g., "halılarımızın" -> "halı", "kitabımız" -> "kitap", "çocuğa" -> "çocuk", "söyle" -> "söylemek")
589
843
  const root = await this.findRoot(word);
590
844
  if (root) {
591
- const cleanWord = word.trim().toLocaleLowerCase("tr-TR");
592
845
  const isInflected = root !== cleanWord;
593
846
  return {
594
847
  isCorrect: true,
@@ -598,42 +851,44 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
598
851
  };
599
852
  }
600
853
 
601
- // 4. No exact match or morphology root: fall back to the closest
602
- // headword (by edit distance) across TDK's full ~81k-word list (the same
603
- // data `getSuggestions()` uses). Restricted to single-token, lowercase
604
- // headwords so it doesn't suggest compounds/phrases or proper nouns.
605
- // Candidates whose length differs too much are skipped before running
606
- // the O(n*m) distance calculation, both for speed and because a huge
607
- // length gap can't be within the distance threshold anyway. Ties (same
608
- // distance) prefer a matching first letter, then a matching length —
609
- // typos rarely change the first letter, and this avoids picking
610
- // whatever happens to sort alphabetically first. There's no word
611
- // frequency data available, so a genuine tie can still land on a
612
- // technically-correct but less commonly intended word.
854
+ // 6. Check if headwords with spaces match when space is removed (e.g. "ön yargı" for "önyargı")
613
855
  if (this.autocompleteCache.length === 0) {
614
856
  this.autocompleteCache = await this.fetchAutocompleteData();
615
857
  }
616
- const cleanWord = word.trim().toLocaleLowerCase("tr-TR");
617
- let best: { candidate: string; distance: number; firstMismatch: number; lengthMismatch: number } | null = null;
858
+ for (const candidate of this.autocompleteCache) {
859
+ if (candidate.includes(" ")) {
860
+ const candidateNoSpace = candidate.replace(/\s+/g, "").toLocaleLowerCase("tr-TR");
861
+ if (candidateNoSpace === cleanWord) {
862
+ return { isCorrect: false, word, suggestion: candidate };
863
+ }
864
+ }
865
+ }
866
+
867
+ // 7. No exact match or morphology root: fall back to closest headword by edit distance.
868
+ // Ties prefer matching first letter, and initial character mismatches are penalized
869
+ // so irrelevant foreign loanwords (like 'jersey') do not beat Turkish roots.
870
+ let best: { candidate: string; distance: number; rawDist: number; firstMismatch: number; lengthMismatch: number } | null = null;
618
871
  for (const candidate of this.autocompleteCache) {
619
872
  if (candidate.includes(" ") || candidate !== candidate.toLocaleLowerCase("tr-TR")) continue;
620
873
  if (Math.abs(candidate.length - cleanWord.length) > 2) continue;
621
874
 
622
- const distance = this.damerauLevenshtein(cleanWord, candidate);
623
- if (distance === 0) continue;
875
+ const rawDist = this.damerauLevenshtein(cleanWord, candidate);
876
+ if (rawDist === 0) continue;
624
877
 
625
878
  const firstMismatch = candidate[0] === cleanWord[0] ? 0 : 1;
626
879
  const lengthMismatch = candidate.length === cleanWord.length ? 0 : 1;
880
+ const distance = rawDist + (firstMismatch > 0 ? 1.2 : 0);
881
+
627
882
  const better =
628
883
  !best ||
629
884
  distance < best.distance ||
630
885
  (distance === best.distance && firstMismatch < best.firstMismatch) ||
631
886
  (distance === best.distance && firstMismatch === best.firstMismatch && lengthMismatch < best.lengthMismatch);
632
887
  if (better) {
633
- best = { candidate, distance, firstMismatch, lengthMismatch };
888
+ best = { candidate, distance, rawDist, firstMismatch, lengthMismatch };
634
889
  }
635
890
  }
636
- if (best && best.distance <= 2) {
891
+ if (best && best.rawDist <= 2 && (best.firstMismatch === 0 || best.rawDist <= 1)) {
637
892
  return { isCorrect: false, word, suggestion: best.candidate };
638
893
  }
639
894
  return { isCorrect: false, word };
@@ -1063,6 +1318,7 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
1063
1318
  origin: originA,
1064
1319
  syllables: this.syllabicate(a),
1065
1320
  harmony: this.checkVowelHarmony(a),
1321
+ labialHarmony: this.checkLabialHarmony(a),
1066
1322
  },
1067
1323
  b: {
1068
1324
  word: b,
@@ -1070,6 +1326,7 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
1070
1326
  origin: originB,
1071
1327
  syllables: this.syllabicate(b),
1072
1328
  harmony: this.checkVowelHarmony(b),
1329
+ labialHarmony: this.checkLabialHarmony(b),
1073
1330
  },
1074
1331
  };
1075
1332
  }
@@ -1181,13 +1438,15 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
1181
1438
 
1182
1439
  /**
1183
1440
  * Syllabicates a Turkish word based on general grammar rules.
1441
+ * Handles syllable separation for vowels, single consonants, double consonants,
1442
+ * and western loanword three-consonant clusters (e.g. e-lek-trik, kon-trol, or-kes-tra).
1184
1443
  */
1185
1444
  public static syllabicate(word: string): string[] {
1186
1445
  const vowels = /[aeıioöuüAEIİOÖUÜ]/;
1446
+ const ONSET_CLUSTERS = new Set(["tr", "pr", "kr", "gr", "br", "fr", "dr", "pl", "kl", "fl", "bl", "gl"]);
1187
1447
  const result: string[] = [];
1188
1448
  let currentSyllable = "";
1189
1449
 
1190
- // Better basic syllabification:
1191
1450
  // Go from right to left.
1192
1451
  for (let i = word.length - 1; i >= 0; i--) {
1193
1452
  currentSyllable = word[i] + currentSyllable;
@@ -1200,9 +1459,14 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
1200
1459
  currentSyllable = word[i - 1] + currentSyllable;
1201
1460
  i--; // skip the consonant
1202
1461
  } else if (i - 2 >= 0 && !vowels.test(word[i - 2])) {
1203
- // two consonants before this vowel. The one right before belongs to this syllable
1204
- currentSyllable = word[i - 1] + currentSyllable;
1205
- i--;
1462
+ // Two consonants before this vowel. Check if three consonants exist and end in an onset cluster
1463
+ if (i - 3 >= 0 && !vowels.test(word[i - 3]) && ONSET_CLUSTERS.has((word[i - 2] + word[i - 1]).toLowerCase())) {
1464
+ currentSyllable = word[i - 2] + word[i - 1] + currentSyllable;
1465
+ i -= 2;
1466
+ } else {
1467
+ currentSyllable = word[i - 1] + currentSyllable;
1468
+ i--;
1469
+ }
1206
1470
  }
1207
1471
  }
1208
1472
  result.unshift(currentSyllable);
@@ -1236,4 +1500,463 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
1236
1500
  // If it has both front and back vowels, it breaks harmony.
1237
1501
  return !(hasBack && hasFront);
1238
1502
  }
1503
+
1504
+ /**
1505
+ * Checks if a word follows Turkish Minor Vowel Harmony (Küçük Ünlü Uyumu / Labial Harmony).
1506
+ * Rules:
1507
+ * 1. After an unrounded vowel (a, e, ı, i), only unrounded vowels (a, e, ı, i) can follow.
1508
+ * 2. After a rounded vowel (o, ö, u, ü), either an unrounded wide (a, e) or rounded narrow (u, ü) vowel can follow.
1509
+ * Single-syllable words and words with <=1 vowel are considered compliant by convention.
1510
+ */
1511
+ public static checkLabialHarmony(word: string): boolean {
1512
+ const lower = word.toLocaleLowerCase("tr-TR");
1513
+ const vowels = lower.split("").filter((ch) => "aeıioöuü".includes(ch));
1514
+ if (vowels.length <= 1) return true;
1515
+
1516
+ for (let i = 0; i < vowels.length - 1; i++) {
1517
+ const v1 = vowels[i];
1518
+ const v2 = vowels[i + 1];
1519
+
1520
+ if ("aeıi".includes(v1)) {
1521
+ if (!"aeıi".includes(v2)) return false;
1522
+ } else if ("oöuü".includes(v1)) {
1523
+ if (!"aeuü".includes(v2)) return false;
1524
+ }
1525
+ }
1526
+ return true;
1527
+ }
1528
+
1529
+ /**
1530
+ * Searches TDK headwords using a wildcard / pattern string.
1531
+ * Wildcards:
1532
+ * '_' or '?' matches any single character
1533
+ * '*' matches zero or more characters
1534
+ * Example: "k_l_m" matches "kalem", "kelam", "kilim".
1535
+ * Runs in-memory against TDK's 81k headword list.
1536
+ */
1537
+ public static async patternSearch(pattern: string, options?: PatternSearchOptions): Promise<string[]> {
1538
+ if (!pattern || pattern.trim() === "") return [];
1539
+ await this.ensureAutocompleteLoaded();
1540
+
1541
+ const cleanPattern = pattern.trim().toLocaleLowerCase("tr-TR");
1542
+ const escaped = cleanPattern
1543
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
1544
+ .replace(/[_?]/g, "[\\p{L}]")
1545
+ .replace(/\*/g, "[\\p{L}]*");
1546
+ const regex = new RegExp(`^${escaped}$`, "u");
1547
+
1548
+ const max = options?.maxResults ?? 50;
1549
+ const matches: string[] = [];
1550
+
1551
+ for (const headword of this.autocompleteCache) {
1552
+ const lower = headword.toLocaleLowerCase("tr-TR");
1553
+ if (regex.test(lower)) {
1554
+ matches.push(headword);
1555
+ if (matches.length >= max) break;
1556
+ }
1557
+ }
1558
+ return matches;
1559
+ }
1560
+
1561
+ /**
1562
+ * Finds headwords in TDK that can be formed from the given letters (anagrams).
1563
+ * If exact-length anagrams exist, they are returned.
1564
+ * If none exist (or exactLength is false), valid sub-anagrams (words using a subset of the letters,
1565
+ * minimum 3 letters) are returned, sorted by length descending.
1566
+ */
1567
+ public static async findAnagrams(letters: string, options?: AnagramOptions): Promise<string[]> {
1568
+ if (!letters || letters.trim() === "") return [];
1569
+ await this.ensureAutocompleteLoaded();
1570
+
1571
+ const clean = letters.trim().toLocaleLowerCase("tr-TR").replace(/[^a-zçğıöşüâîû]/gi, "");
1572
+ if (clean.length === 0) return [];
1573
+
1574
+ const forceExact = options?.exactLength === true;
1575
+ const max = options?.maxResults ?? 50;
1576
+
1577
+ const getFrequency = (str: string): Record<string, number> => {
1578
+ const freq: Record<string, number> = {};
1579
+ for (const ch of str) {
1580
+ freq[ch] = (freq[ch] || 0) + 1;
1581
+ }
1582
+ return freq;
1583
+ };
1584
+
1585
+ const targetFreq = getFrequency(clean);
1586
+ const exactMatches: string[] = [];
1587
+ const subMatches: string[] = [];
1588
+
1589
+ for (const headword of this.autocompleteCache) {
1590
+ const lower = headword.toLocaleLowerCase("tr-TR");
1591
+ if (lower.includes(" ") || lower.includes("-")) continue;
1592
+ if (lower.length > clean.length || lower.length < 3) continue;
1593
+
1594
+ const wordFreq = getFrequency(lower);
1595
+ let isValid = true;
1596
+ for (const [ch, count] of Object.entries(wordFreq)) {
1597
+ if (!targetFreq[ch] || targetFreq[ch] < count) {
1598
+ isValid = false;
1599
+ break;
1600
+ }
1601
+ }
1602
+
1603
+ if (isValid && lower !== clean) {
1604
+ if (lower.length === clean.length) {
1605
+ exactMatches.push(headword);
1606
+ } else {
1607
+ subMatches.push(headword);
1608
+ }
1609
+ }
1610
+ }
1611
+
1612
+ if (exactMatches.length > 0 || forceExact) {
1613
+ return exactMatches.slice(0, max);
1614
+ }
1615
+
1616
+ subMatches.sort((a, b) => b.length - a.length || a.localeCompare(b, "tr-TR"));
1617
+ return subMatches.slice(0, max);
1618
+ }
1619
+
1620
+ /**
1621
+ * Finds words in TDK that rhyme with the given word (sharing the same ending suffix/letters).
1622
+ * @param word The target word
1623
+ * @param options.minLetters Minimum number of ending characters that must match (default: 3)
1624
+ * @param options.maxResults Maximum number of rhyme results to return (default: 50)
1625
+ */
1626
+ public static async findRhymes(word: string, options?: RhymeOptions): Promise<string[]> {
1627
+ if (!word || word.trim() === "") return [];
1628
+ await this.ensureAutocompleteLoaded();
1629
+
1630
+ const clean = word.trim().toLocaleLowerCase("tr-TR");
1631
+ const minLetters = Math.min(options?.minLetters ?? 3, clean.length);
1632
+ const max = options?.maxResults ?? 50;
1633
+
1634
+ const suffix = clean.slice(-minLetters);
1635
+ const results: string[] = [];
1636
+
1637
+ for (const headword of this.autocompleteCache) {
1638
+ const lower = headword.toLocaleLowerCase("tr-TR");
1639
+ if (lower !== clean && lower.endsWith(suffix) && !lower.includes(" ")) {
1640
+ results.push(headword);
1641
+ if (results.length >= max) break;
1642
+ }
1643
+ }
1644
+
1645
+ return results;
1646
+ }
1647
+
1648
+ /**
1649
+ * Performs comprehensive spelling, grammar, and syntax proofreading on a Turkish text.
1650
+ * Detects:
1651
+ * 1. Conjunction 'da/de' erroneously joined to verbs or words (e.g. "gitsende" -> "gitsen de")
1652
+ * 2. Conjunction 'ki' erroneously joined to verbs (e.g. "gördümki" -> "gördüm ki"), respecting SOMBAHÇEMİ exceptions
1653
+ * 3. Question particle 'mi/mı/mu/mü' erroneously joined to words (e.g. "geldimi" -> "geldi mi")
1654
+ * 4. Misspelled words with dictionary suggestions (via edit-distance & morphology)
1655
+ */
1656
+ public static async proofread(text: string): Promise<ProofreadResult> {
1657
+ if (!text || text.trim() === "") {
1658
+ return { text: text || "", issues: [], isCorrect: true };
1659
+ }
1660
+
1661
+ await this.ensureAutocompleteLoaded();
1662
+ const issues: ProofreadIssue[] = [];
1663
+
1664
+ const SOMBAHCEMI = new Set([
1665
+ "sanki", "oysaki", "mademki", "belki", "halbuki", "çünkü", "meğerki", "illaki"
1666
+ ]);
1667
+
1668
+ // 1. Detect multi-word phrases that should be written as single compound words
1669
+ const PHRASE_MISTAKES: {
1670
+ regex: RegExp;
1671
+ suggestion: string;
1672
+ message: string;
1673
+ type: ProofreadIssue["type"];
1674
+ }[] = [
1675
+ {
1676
+ regex: /\bhiç\s+bir\b/gi,
1677
+ suggestion: "hiçbir",
1678
+ message: "'hiçbir' belgisiz sıfatı bitişik yazılmalıdır.",
1679
+ type: "spelling",
1680
+ },
1681
+ {
1682
+ regex: /\bbir\s+çok\b/gi,
1683
+ suggestion: "birçok",
1684
+ message: "'birçok' belgisiz sıfatı/zamiri bitişik yazılmalıdır.",
1685
+ type: "spelling",
1686
+ },
1687
+ {
1688
+ regex: /\bbir\s+kaç\b/gi,
1689
+ suggestion: "birkaç",
1690
+ message: "'birkaç' belgisiz sıfatı/zamiri bitişik yazılmalıdır.",
1691
+ type: "spelling",
1692
+ },
1693
+ {
1694
+ regex: /\bbir\s+az\b/gi,
1695
+ suggestion: "biraz",
1696
+ message: "'biraz' sözcüğü bitişik yazılmalıdır.",
1697
+ type: "spelling",
1698
+ },
1699
+ {
1700
+ regex: /\bher\s+hangi\b/gi,
1701
+ suggestion: "herhangi",
1702
+ message: "'herhangi' sözcüğü bitişik yazılmalıdır.",
1703
+ type: "spelling",
1704
+ },
1705
+ {
1706
+ regex: /\bgit\s+gide\b/gi,
1707
+ suggestion: "gitgide",
1708
+ message: "'gitgide' zarfı bitişik yazılmalıdır.",
1709
+ type: "spelling",
1710
+ },
1711
+ {
1712
+ regex: /\bbirden\s+bire\b/gi,
1713
+ suggestion: "birdenbire",
1714
+ message: "'birdenbire' zarfı bitişik yazılmalıdır.",
1715
+ type: "spelling",
1716
+ },
1717
+ {
1718
+ regex: /\brast\s+gele\b/gi,
1719
+ suggestion: "rastgele",
1720
+ message: "'rastgele' zarfı bitişik yazılmalıdır.",
1721
+ type: "spelling",
1722
+ },
1723
+ ];
1724
+
1725
+ const coveredRanges: { start: number; end: number }[] = [];
1726
+ for (const pm of PHRASE_MISTAKES) {
1727
+ let pmMatch: RegExpExecArray | null;
1728
+ while ((pmMatch = pm.regex.exec(text)) !== null) {
1729
+ const start = pmMatch.index;
1730
+ const end = start + pmMatch[0].length;
1731
+ coveredRanges.push({ start, end });
1732
+ issues.push({
1733
+ type: pm.type,
1734
+ word: pmMatch[0],
1735
+ startIndex: start,
1736
+ endIndex: end,
1737
+ suggestion: pm.suggestion,
1738
+ message: pm.message,
1739
+ });
1740
+ }
1741
+ }
1742
+
1743
+ const tokenRegex = /[\p{L}0-9'’]+/gu;
1744
+ let match: RegExpExecArray | null;
1745
+
1746
+ while ((match = tokenRegex.exec(text)) !== null) {
1747
+ const rawWord = match[0];
1748
+ const startIndex = match.index;
1749
+ const endIndex = startIndex + rawWord.length;
1750
+ const lower = rawWord.toLocaleLowerCase("tr-TR");
1751
+
1752
+ if (/^\d+$/.test(lower)) continue;
1753
+ if (coveredRanges.some((r) => startIndex >= r.start && endIndex <= r.end)) continue;
1754
+
1755
+ let flagged = false;
1756
+
1757
+ // 1. Check Question Particle (mı, mi, mu, mü) erroneously attached
1758
+ const questionMatch = lower.match(/^(.+?)(m[ıiuü](?:sin|sın|sun|sün|siniz|sınız|sunuz|sünüz|yiz|yız|yuz|yüz|m|k)?)$/);
1759
+ if (questionMatch) {
1760
+ const base = questionMatch[1];
1761
+ const particle = questionMatch[2];
1762
+ if (base.length >= 2 && (await this.isHeadword(base) || (await this.findRoot(base)) !== null)) {
1763
+ if (!(await this.isHeadword(lower))) {
1764
+ issues.push({
1765
+ type: "question_particle",
1766
+ word: rawWord,
1767
+ startIndex,
1768
+ endIndex,
1769
+ suggestion: `${base} ${particle}`,
1770
+ message: `'${particle}' soru eki kendinden önceki kelimeden ayrı yazılmalıdır.`,
1771
+ });
1772
+ flagged = true;
1773
+ }
1774
+ }
1775
+ }
1776
+
1777
+ const VERB_CONJUGATION_REGEX =
1778
+ /(?:d[ıiuü][kmmn]?|t[ıiuü][kmmn]?|d[ıiuü]n[ıiuü]z?|t[ıiuü]n[ıiuü]z?|m[ıiuü]ş(?:[szn][ıiuü]z?|lar)?|yor(?:um|sun|uz|lar)?|ecek(?:sin|iz|ler)?|acak(?:sın|ız|lar)?|s[ae][mnk]|s[ae]n[ıiz]?|meli|malı|me[mz]|ma[mz])$/i;
1779
+
1780
+ // 2. Check Conjunction 'ki' erroneously attached to verbs
1781
+ if (!flagged && lower.endsWith("ki") && lower.length > 3) {
1782
+ const base = lower.slice(0, -2);
1783
+ if (!SOMBAHCEMI.has(lower)) {
1784
+ if (!(await this.isHeadword(lower))) {
1785
+ const root = await this.findRoot(base);
1786
+ const isVerb =
1787
+ (base === "demek" || base === "kaldı" || base === "yeter" || base === "bilmem" || VERB_CONJUGATION_REGEX.test(base)) &&
1788
+ (root ? root.endsWith("mek") || root.endsWith("mak") : true);
1789
+
1790
+ if (isVerb) {
1791
+ issues.push({
1792
+ type: "conjunction_ki",
1793
+ word: rawWord,
1794
+ startIndex,
1795
+ endIndex,
1796
+ suggestion: `${base} ki`,
1797
+ message: `'ki' bağlacı ayrı yazılmalıdır.`,
1798
+ });
1799
+ flagged = true;
1800
+ }
1801
+ }
1802
+ }
1803
+ }
1804
+
1805
+ // 3. Check Conjunction 'da/de/ta/te' erroneously attached to verbs
1806
+ if (!flagged && (lower.endsWith("de") || lower.endsWith("da") || lower.endsWith("te") || lower.endsWith("ta")) && lower.length > 3) {
1807
+ const base = lower.slice(0, -2);
1808
+ const ending = lower.slice(-2);
1809
+ if (!(await this.isHeadword(lower))) {
1810
+ const root = await this.findRoot(base);
1811
+ const isVerb =
1812
+ VERB_CONJUGATION_REGEX.test(base) &&
1813
+ (root ? root.endsWith("mek") || root.endsWith("mak") : false);
1814
+
1815
+ if (isVerb) {
1816
+ const correctEnding = ending.startsWith("t") ? (ending === "te" ? "de" : "da") : ending;
1817
+ issues.push({
1818
+ type: "conjunction_da",
1819
+ word: rawWord,
1820
+ startIndex,
1821
+ endIndex,
1822
+ suggestion: `${base} ${correctEnding}`,
1823
+ message: `'da/de' bağlacı fiillerden sonra her zaman ayrı yazılır (bağlaç olan da/de sertleşmez).`,
1824
+ });
1825
+ flagged = true;
1826
+ }
1827
+ }
1828
+ }
1829
+
1830
+ // 4. Check -şey / -sey erroneously attached to preceding word
1831
+ const seyMatch = lower.match(/^(.+?)(?:şey|sey)([ıiuaeüodekmnl]+)?$/);
1832
+ if (!flagged && seyMatch && !SEY_EXCEPTIONS.has(lower)) {
1833
+ let prefix = seyMatch[1];
1834
+ const suffix = seyMatch[2] || "";
1835
+ if (prefix === "hicbir") prefix = "hiçbir";
1836
+ if (prefix === "cok") prefix = "çok";
1837
+ issues.push({
1838
+ type: "spelling",
1839
+ word: rawWord,
1840
+ startIndex,
1841
+ endIndex,
1842
+ suggestion: `${prefix} şey${suffix}`,
1843
+ message: "'şey' sözcüğü kendinden önceki kelimeden ayrı yazılmalıdır.",
1844
+ });
1845
+ flagged = true;
1846
+ }
1847
+
1848
+ // 5. Check 'yada' conjunction mistake
1849
+ if (!flagged && lower === "yada") {
1850
+ issues.push({
1851
+ type: "spelling",
1852
+ word: rawWord,
1853
+ startIndex,
1854
+ endIndex,
1855
+ suggestion: "ya da",
1856
+ message: "'ya da' bağlacı her zaman ayrı yazılır.",
1857
+ });
1858
+ flagged = true;
1859
+ }
1860
+
1861
+ // 6. Check common vowel drop mistakes: burda, şurda, orda, vb. (TDK Kural 15)
1862
+ if (!flagged && (lower === "burda" || lower === "şurda" || lower === "surda" || lower === "orda" || lower === "içerde" || lower === "icerde" || lower === "dışarda" || lower === "disarda" || lower === "yukarda")) {
1863
+ const correct = COMMON_MISSPELLINGS[lower] || lower;
1864
+ issues.push({
1865
+ type: "spelling",
1866
+ word: rawWord,
1867
+ startIndex,
1868
+ endIndex,
1869
+ suggestion: correct,
1870
+ message: `'${rawWord}' sözcüğünde ünlü düşmesi yapılmaz.`,
1871
+ });
1872
+ flagged = true;
1873
+ }
1874
+
1875
+ // 7. General Spell Check
1876
+ if (!flagged) {
1877
+ const check = await this.checkSpelling(rawWord);
1878
+ if (!check.isCorrect) {
1879
+ issues.push({
1880
+ type: "spelling",
1881
+ word: rawWord,
1882
+ startIndex,
1883
+ endIndex,
1884
+ suggestion: check.suggestion,
1885
+ message: check.suggestion
1886
+ ? `'${rawWord}' yanlış yazılmış olabilir.`
1887
+ : `'${rawWord}' sözlükte bulunamadı.`,
1888
+ });
1889
+ }
1890
+ }
1891
+ }
1892
+
1893
+ issues.sort((a, b) => a.startIndex - b.startIndex);
1894
+
1895
+ return {
1896
+ text,
1897
+ issues,
1898
+ isCorrect: issues.length === 0,
1899
+ };
1900
+ }
1901
+ }
1902
+
1903
+ /**
1904
+ * Configurable instance-based client for TDK API.
1905
+ * Useful for multi-tenant applications or backend services requiring isolated configurations.
1906
+ */
1907
+ export class TDKClient {
1908
+ constructor(config?: TDKConfig) {
1909
+ if (config) {
1910
+ TDK.configure(config);
1911
+ }
1912
+ }
1913
+
1914
+ public getWord(word: string): Promise<WordInfo[]> {
1915
+ return TDK.getWord(word);
1916
+ }
1917
+
1918
+ public getMeanings(word: string): Promise<string[]> {
1919
+ return TDK.getMeanings(word);
1920
+ }
1921
+
1922
+ public checkSpelling(word: string): Promise<SpellCheckResult> {
1923
+ return TDK.checkSpelling(word);
1924
+ }
1925
+
1926
+ public findRoot(word: string): Promise<string | null> {
1927
+ return TDK.findRoot(word);
1928
+ }
1929
+
1930
+ public stem(word: string): Promise<StemResult | null> {
1931
+ return TDK.stem(word);
1932
+ }
1933
+
1934
+ public proofread(text: string): Promise<ProofreadResult> {
1935
+ return TDK.proofread(text);
1936
+ }
1937
+
1938
+ public patternSearch(pattern: string, options?: PatternSearchOptions): Promise<string[]> {
1939
+ return TDK.patternSearch(pattern, options);
1940
+ }
1941
+
1942
+ public findAnagrams(letters: string, options?: AnagramOptions): Promise<string[]> {
1943
+ return TDK.findAnagrams(letters, options);
1944
+ }
1945
+
1946
+ public findRhymes(word: string, options?: RhymeOptions): Promise<string[]> {
1947
+ return TDK.findRhymes(word, options);
1948
+ }
1949
+
1950
+ public syllabicate(word: string): string[] {
1951
+ return TDK.syllabicate(word);
1952
+ }
1953
+
1954
+ public checkVowelHarmony(word: string): boolean {
1955
+ return TDK.checkVowelHarmony(word);
1956
+ }
1957
+
1958
+ public checkLabialHarmony(word: string): boolean {
1959
+ return TDK.checkLabialHarmony(word);
1960
+ }
1239
1961
  }
1962
+