tdk-api-wrapper 1.3.1 → 1.5.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
@@ -2,6 +2,7 @@ import type {
2
2
  WordInfo,
3
3
  DailyContent,
4
4
  SpellCheckResult,
5
+ StemResult,
5
6
  WordOfTheDay,
6
7
  DailyPick,
7
8
  WordComparison,
@@ -9,8 +10,15 @@ import type {
9
10
  TDKRule,
10
11
  KubbealtiEntry,
11
12
  WiktionaryEntry,
13
+ ProofreadIssue,
14
+ ProofreadResult,
15
+ PatternSearchOptions,
16
+ AnagramOptions,
17
+ RhymeOptions,
18
+ TDKConfig,
12
19
  } from "./types";
13
20
  import { TDKValidationError, TDKNetworkError } from "./errors";
21
+ import { getStemCandidates } from "./morphology";
14
22
  import * as fs from "node:fs";
15
23
  import * as path from "node:path";
16
24
  import * as os from "node:os";
@@ -102,11 +110,28 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
102
110
  -----END CERTIFICATE-----`,
103
111
  ];
104
112
 
113
+ // Configuration
114
+ private static defaultTimeoutMs = 8000;
115
+ private static defaultRetries = 1;
116
+ private static maxCacheSize = 1000;
117
+
105
118
  // Cache Mechanism
106
119
  private static isCacheEnabled = false;
107
120
  private static wordCache = new Map<string, WordInfo[]>();
108
121
  private static dailyContentCache: DailyContent | null = null;
109
122
  private static autocompleteCache: string[] = [];
123
+ private static autocompleteSet: Set<string> = new Set<string>();
124
+ private static stemCache = new Map<string, string | null>();
125
+
126
+ /**
127
+ * Configures global client options such as network timeout, retries, and cache size.
128
+ */
129
+ public static configure(config: TDKConfig): void {
130
+ if (config.timeoutMs !== undefined) this.defaultTimeoutMs = Math.max(100, config.timeoutMs);
131
+ if (config.retries !== undefined) this.defaultRetries = Math.max(0, config.retries);
132
+ if (config.cache !== undefined) this.enableCache(config.cache);
133
+ if (config.maxCacheSize !== undefined) this.maxCacheSize = Math.max(10, config.maxCacheSize);
134
+ }
110
135
 
111
136
  /**
112
137
  * Enables or disables in-memory caching for API requests.
@@ -125,12 +150,62 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
125
150
  this.wordCache.clear();
126
151
  this.dailyContentCache = null;
127
152
  this.autocompleteCache = [];
153
+ this.autocompleteSet.clear();
154
+ this.stemCache.clear();
155
+ }
156
+
157
+ private static setBoundedCache<K, V>(map: Map<K, V>, key: K, value: V): void {
158
+ if (map.size >= this.maxCacheSize) {
159
+ const firstKey = map.keys().next().value;
160
+ if (firstKey !== undefined) map.delete(firstKey);
161
+ }
162
+ map.set(key, value);
128
163
  }
129
164
 
130
165
  private static delay(ms: number) {
131
166
  return new Promise((resolve) => setTimeout(resolve, ms));
132
167
  }
133
168
 
169
+ /**
170
+ * Internal helper that performs HTTP fetch with timeout and automatic retry on network/5xx errors.
171
+ */
172
+ private static async fetchWithRetry(
173
+ url: string,
174
+ options: RequestInit = {},
175
+ retries: number = this.defaultRetries,
176
+ timeoutMs: number = this.defaultTimeoutMs
177
+ ): Promise<Response> {
178
+ let lastError: unknown;
179
+ for (let attempt = 0; attempt <= retries; attempt++) {
180
+ try {
181
+ const signal = AbortSignal.timeout(timeoutMs);
182
+ const headers = {
183
+ "User-Agent": "TDK-API-Nodejs-Wrapper/1.0",
184
+ ...((options.headers as Record<string, string>) || {}),
185
+ };
186
+ const res = await fetch(url, { ...options, headers, signal });
187
+ if (res.ok || (res.status >= 400 && res.status < 500)) {
188
+ return res;
189
+ }
190
+ // If 5xx server error, retry
191
+ if (attempt < retries) {
192
+ await this.delay(200 * (attempt + 1));
193
+ continue;
194
+ }
195
+ return res;
196
+ } catch (err) {
197
+ lastError = err;
198
+ if (attempt < retries) {
199
+ await this.delay(200 * (attempt + 1));
200
+ continue;
201
+ }
202
+ }
203
+ }
204
+ throw new TDKNetworkError(`Request to ${url} failed after ${retries + 1} attempts.`, {
205
+ cause: lastError,
206
+ });
207
+ }
208
+
134
209
  /**
135
210
  * Fetches detailed information for a given word from the TDK Dictionary.
136
211
  */
@@ -149,9 +224,7 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
149
224
 
150
225
  let response: Response;
151
226
  try {
152
- response = await fetch(url, {
153
- headers: { "User-Agent": "TDK-API-Nodejs-Wrapper/1.0" },
154
- });
227
+ response = await this.fetchWithRetry(url);
155
228
  } catch (error) {
156
229
  throw new TDKNetworkError("Failed to fetch word from TDK: request failed.", { cause: error });
157
230
  }
@@ -170,13 +243,13 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
170
243
  }
171
244
 
172
245
  if (!Array.isArray(data) && data && "error" in (data as Record<string, unknown>)) {
173
- if (this.isCacheEnabled) this.wordCache.set(cleanWord, []);
246
+ if (this.isCacheEnabled) this.setBoundedCache(this.wordCache, cleanWord, []);
174
247
  return [];
175
248
  }
176
249
 
177
250
  const results = data as WordInfo[];
178
251
  if (this.isCacheEnabled) {
179
- this.wordCache.set(cleanWord, results);
252
+ this.setBoundedCache(this.wordCache, cleanWord, results);
180
253
  }
181
254
  return results;
182
255
  }
@@ -241,6 +314,18 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
241
314
  }
242
315
  }
243
316
 
317
+ /**
318
+ * Ensures TDK's ~81k headword list is loaded in memory for fast O(1) set operations.
319
+ */
320
+ private static async ensureAutocompleteLoaded(): Promise<void> {
321
+ if (this.autocompleteCache.length === 0) {
322
+ this.autocompleteCache = await this.fetchAutocompleteData();
323
+ this.autocompleteSet = new Set(
324
+ this.autocompleteCache.map((w) => w.toLocaleLowerCase("tr-TR"))
325
+ );
326
+ }
327
+ }
328
+
244
329
  /**
245
330
  * Returns autocomplete suggestions for a given prefix, searched over TDK's
246
331
  * full headword list (see `fetchAutocompleteData`). The list is fetched
@@ -250,9 +335,7 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
250
335
  public static async getSuggestions(prefix: string): Promise<string[]> {
251
336
  if (!prefix || prefix.trim() === "") return [];
252
337
 
253
- if (this.autocompleteCache.length === 0) {
254
- this.autocompleteCache = await this.fetchAutocompleteData();
255
- }
338
+ await this.ensureAutocompleteLoaded();
256
339
 
257
340
  const cleanPrefix = prefix.trim().toLocaleLowerCase("tr-TR");
258
341
  return this.autocompleteCache
@@ -260,6 +343,88 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
260
343
  .slice(0, 10);
261
344
  }
262
345
 
346
+ /**
347
+ * Checks whether a word exists as a known headword in TDK dictionary.
348
+ * Checks in-memory autocompleteSet (81k headwords) if loaded, or queries TDK API.
349
+ */
350
+ public static async isHeadword(word: string): Promise<boolean> {
351
+ if (!word || word.trim() === "") return false;
352
+ const clean = word.trim().toLocaleLowerCase("tr-TR");
353
+
354
+ await this.ensureAutocompleteLoaded();
355
+ if (this.autocompleteSet.size > 0) {
356
+ return this.autocompleteSet.has(clean);
357
+ }
358
+
359
+ try {
360
+ const results = await this.getWord(clean);
361
+ return results.length > 0;
362
+ } catch {
363
+ return false;
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Generates candidate roots for a given Turkish word using progressive BFS suffix stripping,
369
+ * consonant mutation restoration, and vowel drop restoration.
370
+ */
371
+ public static getStemCandidates(word: string): string[] {
372
+ return getStemCandidates(word);
373
+ }
374
+
375
+ /**
376
+ * Finds the dictionary root (headword) of a word by checking direct existence
377
+ * and evaluating candidate stems generated by morphological analysis.
378
+ * Returns the root headword string if found, or null if no match in TDK.
379
+ */
380
+ public static async findRoot(word: string): Promise<string | null> {
381
+ if (!word || word.trim() === "") return null;
382
+ const clean = word.trim().toLocaleLowerCase("tr-TR");
383
+
384
+ if (this.stemCache.has(clean)) {
385
+ return this.stemCache.get(clean)!;
386
+ }
387
+
388
+ // 1. If the word itself is an exact headword, it is its own root
389
+ if (await this.isHeadword(clean)) {
390
+ this.setBoundedCache(this.stemCache, clean, clean);
391
+ return clean;
392
+ }
393
+
394
+ // 2. Test morphological stem candidates
395
+ const candidates = getStemCandidates(clean);
396
+ for (const candidate of candidates) {
397
+ if (await this.isHeadword(candidate)) {
398
+ this.setBoundedCache(this.stemCache, clean, candidate);
399
+ return candidate;
400
+ }
401
+ }
402
+
403
+ this.setBoundedCache(this.stemCache, clean, null);
404
+ return null;
405
+ }
406
+
407
+ /**
408
+ * Performs morphological stemming on a Turkish word.
409
+ * Returns a StemResult containing the original word, resolved root, and whether it is inflected.
410
+ */
411
+ public static async stem(word: string): Promise<StemResult | null> {
412
+ if (!word || word.trim() === "") return null;
413
+ const clean = word.trim().toLocaleLowerCase("tr-TR");
414
+ const root = await this.findRoot(word);
415
+
416
+ if (!root) {
417
+ return null;
418
+ }
419
+
420
+ return {
421
+ word,
422
+ root,
423
+ isInflected: root !== clean,
424
+ candidates: getStemCandidates(word),
425
+ };
426
+ }
427
+
263
428
  /**
264
429
  * Returns a list of proverbs and idioms containing the word.
265
430
  */
@@ -486,7 +651,21 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
486
651
  }
487
652
  }
488
653
 
489
- // 3. No exact match in TDK's curated lists: fall back to the closest
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")
656
+ const root = await this.findRoot(word);
657
+ if (root) {
658
+ const cleanWord = word.trim().toLocaleLowerCase("tr-TR");
659
+ const isInflected = root !== cleanWord;
660
+ return {
661
+ isCorrect: true,
662
+ word,
663
+ isInflected,
664
+ root,
665
+ };
666
+ }
667
+
668
+ // 4. No exact match or morphology root: fall back to the closest
490
669
  // headword (by edit distance) across TDK's full ~81k-word list (the same
491
670
  // data `getSuggestions()` uses). Restricted to single-token, lowercase
492
671
  // headwords so it doesn't suggest compounds/phrases or proper nouns.
@@ -951,6 +1130,7 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
951
1130
  origin: originA,
952
1131
  syllables: this.syllabicate(a),
953
1132
  harmony: this.checkVowelHarmony(a),
1133
+ labialHarmony: this.checkLabialHarmony(a),
954
1134
  },
955
1135
  b: {
956
1136
  word: b,
@@ -958,6 +1138,7 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
958
1138
  origin: originB,
959
1139
  syllables: this.syllabicate(b),
960
1140
  harmony: this.checkVowelHarmony(b),
1141
+ labialHarmony: this.checkLabialHarmony(b),
961
1142
  },
962
1143
  };
963
1144
  }
@@ -997,13 +1178,30 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
997
1178
 
998
1179
  const analyses: WordAnalysis[] = [];
999
1180
  for (const word of unique) {
1000
- const results = await this.getWord(word);
1001
- const found = results.length > 0;
1181
+ let results = await this.getWord(word);
1182
+ let found = results.length > 0;
1183
+ let root: string | undefined;
1184
+ let isInflected: boolean | undefined;
1185
+
1186
+ if (!found) {
1187
+ const resolvedRoot = await this.findRoot(word);
1188
+ if (resolvedRoot) {
1189
+ results = await this.getWord(resolvedRoot);
1190
+ if (results.length > 0) {
1191
+ found = true;
1192
+ root = resolvedRoot;
1193
+ isInflected = true;
1194
+ }
1195
+ }
1196
+ }
1197
+
1002
1198
  analyses.push({
1003
1199
  word,
1004
1200
  found,
1005
1201
  meaning: found ? this.firstMeaning(results) : null,
1006
1202
  origin: found ? results[0].lisan || "Türkçe" : null,
1203
+ root,
1204
+ isInflected,
1007
1205
  });
1008
1206
  await this.delay(200);
1009
1207
  }
@@ -1052,13 +1250,15 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
1052
1250
 
1053
1251
  /**
1054
1252
  * Syllabicates a Turkish word based on general grammar rules.
1253
+ * Handles syllable separation for vowels, single consonants, double consonants,
1254
+ * and western loanword three-consonant clusters (e.g. e-lek-trik, kon-trol, or-kes-tra).
1055
1255
  */
1056
1256
  public static syllabicate(word: string): string[] {
1057
1257
  const vowels = /[aeıioöuüAEIİOÖUÜ]/;
1258
+ const ONSET_CLUSTERS = new Set(["tr", "pr", "kr", "gr", "br", "fr", "dr", "pl", "kl", "fl", "bl", "gl"]);
1058
1259
  const result: string[] = [];
1059
1260
  let currentSyllable = "";
1060
1261
 
1061
- // Better basic syllabification:
1062
1262
  // Go from right to left.
1063
1263
  for (let i = word.length - 1; i >= 0; i--) {
1064
1264
  currentSyllable = word[i] + currentSyllable;
@@ -1071,9 +1271,14 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
1071
1271
  currentSyllable = word[i - 1] + currentSyllable;
1072
1272
  i--; // skip the consonant
1073
1273
  } else if (i - 2 >= 0 && !vowels.test(word[i - 2])) {
1074
- // two consonants before this vowel. The one right before belongs to this syllable
1075
- currentSyllable = word[i - 1] + currentSyllable;
1076
- i--;
1274
+ // Two consonants before this vowel. Check if three consonants exist and end in an onset cluster
1275
+ if (i - 3 >= 0 && !vowels.test(word[i - 3]) && ONSET_CLUSTERS.has((word[i - 2] + word[i - 1]).toLowerCase())) {
1276
+ currentSyllable = word[i - 2] + word[i - 1] + currentSyllable;
1277
+ i -= 2;
1278
+ } else {
1279
+ currentSyllable = word[i - 1] + currentSyllable;
1280
+ i--;
1281
+ }
1077
1282
  }
1078
1283
  }
1079
1284
  result.unshift(currentSyllable);
@@ -1107,4 +1312,344 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
1107
1312
  // If it has both front and back vowels, it breaks harmony.
1108
1313
  return !(hasBack && hasFront);
1109
1314
  }
1315
+
1316
+ /**
1317
+ * Checks if a word follows Turkish Minor Vowel Harmony (Küçük Ünlü Uyumu / Labial Harmony).
1318
+ * Rules:
1319
+ * 1. After an unrounded vowel (a, e, ı, i), only unrounded vowels (a, e, ı, i) can follow.
1320
+ * 2. After a rounded vowel (o, ö, u, ü), either an unrounded wide (a, e) or rounded narrow (u, ü) vowel can follow.
1321
+ * Single-syllable words and words with <=1 vowel are considered compliant by convention.
1322
+ */
1323
+ public static checkLabialHarmony(word: string): boolean {
1324
+ const lower = word.toLocaleLowerCase("tr-TR");
1325
+ const vowels = lower.split("").filter((ch) => "aeıioöuü".includes(ch));
1326
+ if (vowels.length <= 1) return true;
1327
+
1328
+ for (let i = 0; i < vowels.length - 1; i++) {
1329
+ const v1 = vowels[i];
1330
+ const v2 = vowels[i + 1];
1331
+
1332
+ if ("aeıi".includes(v1)) {
1333
+ if (!"aeıi".includes(v2)) return false;
1334
+ } else if ("oöuü".includes(v1)) {
1335
+ if (!"aeuü".includes(v2)) return false;
1336
+ }
1337
+ }
1338
+ return true;
1339
+ }
1340
+
1341
+ /**
1342
+ * Searches TDK headwords using a wildcard / pattern string.
1343
+ * Wildcards:
1344
+ * '_' or '?' matches any single character
1345
+ * '*' matches zero or more characters
1346
+ * Example: "k_l_m" matches "kalem", "kelam", "kilim".
1347
+ * Runs in-memory against TDK's 81k headword list.
1348
+ */
1349
+ public static async patternSearch(pattern: string, options?: PatternSearchOptions): Promise<string[]> {
1350
+ if (!pattern || pattern.trim() === "") return [];
1351
+ await this.ensureAutocompleteLoaded();
1352
+
1353
+ const cleanPattern = pattern.trim().toLocaleLowerCase("tr-TR");
1354
+ const escaped = cleanPattern
1355
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
1356
+ .replace(/[_?]/g, "[\\p{L}]")
1357
+ .replace(/\*/g, "[\\p{L}]*");
1358
+ const regex = new RegExp(`^${escaped}$`, "u");
1359
+
1360
+ const max = options?.maxResults ?? 50;
1361
+ const matches: string[] = [];
1362
+
1363
+ for (const headword of this.autocompleteCache) {
1364
+ const lower = headword.toLocaleLowerCase("tr-TR");
1365
+ if (regex.test(lower)) {
1366
+ matches.push(headword);
1367
+ if (matches.length >= max) break;
1368
+ }
1369
+ }
1370
+ return matches;
1371
+ }
1372
+
1373
+ /**
1374
+ * Finds headwords in TDK that can be formed from the given letters (anagrams).
1375
+ * If exact-length anagrams exist, they are returned.
1376
+ * If none exist (or exactLength is false), valid sub-anagrams (words using a subset of the letters,
1377
+ * minimum 3 letters) are returned, sorted by length descending.
1378
+ */
1379
+ public static async findAnagrams(letters: string, options?: AnagramOptions): Promise<string[]> {
1380
+ if (!letters || letters.trim() === "") return [];
1381
+ await this.ensureAutocompleteLoaded();
1382
+
1383
+ const clean = letters.trim().toLocaleLowerCase("tr-TR").replace(/[^a-zçğıöşüâîû]/gi, "");
1384
+ if (clean.length === 0) return [];
1385
+
1386
+ const forceExact = options?.exactLength === true;
1387
+ const max = options?.maxResults ?? 50;
1388
+
1389
+ const getFrequency = (str: string): Record<string, number> => {
1390
+ const freq: Record<string, number> = {};
1391
+ for (const ch of str) {
1392
+ freq[ch] = (freq[ch] || 0) + 1;
1393
+ }
1394
+ return freq;
1395
+ };
1396
+
1397
+ const targetFreq = getFrequency(clean);
1398
+ const exactMatches: string[] = [];
1399
+ const subMatches: string[] = [];
1400
+
1401
+ for (const headword of this.autocompleteCache) {
1402
+ const lower = headword.toLocaleLowerCase("tr-TR");
1403
+ if (lower.includes(" ") || lower.includes("-")) continue;
1404
+ if (lower.length > clean.length || lower.length < 3) continue;
1405
+
1406
+ const wordFreq = getFrequency(lower);
1407
+ let isValid = true;
1408
+ for (const [ch, count] of Object.entries(wordFreq)) {
1409
+ if (!targetFreq[ch] || targetFreq[ch] < count) {
1410
+ isValid = false;
1411
+ break;
1412
+ }
1413
+ }
1414
+
1415
+ if (isValid && lower !== clean) {
1416
+ if (lower.length === clean.length) {
1417
+ exactMatches.push(headword);
1418
+ } else {
1419
+ subMatches.push(headword);
1420
+ }
1421
+ }
1422
+ }
1423
+
1424
+ if (exactMatches.length > 0 || forceExact) {
1425
+ return exactMatches.slice(0, max);
1426
+ }
1427
+
1428
+ subMatches.sort((a, b) => b.length - a.length || a.localeCompare(b, "tr-TR"));
1429
+ return subMatches.slice(0, max);
1430
+ }
1431
+
1432
+ /**
1433
+ * Finds words in TDK that rhyme with the given word (sharing the same ending suffix/letters).
1434
+ * @param word The target word
1435
+ * @param options.minLetters Minimum number of ending characters that must match (default: 3)
1436
+ * @param options.maxResults Maximum number of rhyme results to return (default: 50)
1437
+ */
1438
+ public static async findRhymes(word: string, options?: RhymeOptions): Promise<string[]> {
1439
+ if (!word || word.trim() === "") return [];
1440
+ await this.ensureAutocompleteLoaded();
1441
+
1442
+ const clean = word.trim().toLocaleLowerCase("tr-TR");
1443
+ const minLetters = Math.min(options?.minLetters ?? 3, clean.length);
1444
+ const max = options?.maxResults ?? 50;
1445
+
1446
+ const suffix = clean.slice(-minLetters);
1447
+ const results: string[] = [];
1448
+
1449
+ for (const headword of this.autocompleteCache) {
1450
+ const lower = headword.toLocaleLowerCase("tr-TR");
1451
+ if (lower !== clean && lower.endsWith(suffix) && !lower.includes(" ")) {
1452
+ results.push(headword);
1453
+ if (results.length >= max) break;
1454
+ }
1455
+ }
1456
+
1457
+ return results;
1458
+ }
1459
+
1460
+ /**
1461
+ * Performs comprehensive spelling, grammar, and syntax proofreading on a Turkish text.
1462
+ * Detects:
1463
+ * 1. Conjunction 'da/de' erroneously joined to verbs or words (e.g. "gitsende" -> "gitsen de")
1464
+ * 2. Conjunction 'ki' erroneously joined to verbs (e.g. "gördümki" -> "gördüm ki"), respecting SOMBAHÇEMİ exceptions
1465
+ * 3. Question particle 'mi/mı/mu/mü' erroneously joined to words (e.g. "geldimi" -> "geldi mi")
1466
+ * 4. Misspelled words with dictionary suggestions (via edit-distance & morphology)
1467
+ */
1468
+ public static async proofread(text: string): Promise<ProofreadResult> {
1469
+ if (!text || text.trim() === "") {
1470
+ return { text: text || "", issues: [], isCorrect: true };
1471
+ }
1472
+
1473
+ await this.ensureAutocompleteLoaded();
1474
+ const issues: ProofreadIssue[] = [];
1475
+
1476
+ const SOMBAHCEMI = new Set([
1477
+ "sanki", "oysaki", "mademki", "belki", "halbuki", "çünkü", "meğerki", "illaki"
1478
+ ]);
1479
+
1480
+ const tokenRegex = /[\p{L}0-9'’]+/gu;
1481
+ let match: RegExpExecArray | null;
1482
+
1483
+ while ((match = tokenRegex.exec(text)) !== null) {
1484
+ const rawWord = match[0];
1485
+ const startIndex = match.index;
1486
+ const endIndex = startIndex + rawWord.length;
1487
+ const lower = rawWord.toLocaleLowerCase("tr-TR");
1488
+
1489
+ if (/^\d+$/.test(lower)) continue;
1490
+
1491
+ let flagged = false;
1492
+
1493
+ // 1. Check Question Particle (mı, mi, mu, mü) erroneously attached
1494
+ 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)?)$/);
1495
+ if (questionMatch) {
1496
+ const base = questionMatch[1];
1497
+ const particle = questionMatch[2];
1498
+ if (base.length >= 2 && (await this.isHeadword(base) || (await this.findRoot(base)) !== null)) {
1499
+ if (!(await this.isHeadword(lower))) {
1500
+ issues.push({
1501
+ type: "question_particle",
1502
+ word: rawWord,
1503
+ startIndex,
1504
+ endIndex,
1505
+ suggestion: `${base} ${particle}`,
1506
+ message: `'${particle}' soru eki kendinden önceki kelimeden ayrı yazılmalıdır.`,
1507
+ });
1508
+ flagged = true;
1509
+ }
1510
+ }
1511
+ }
1512
+
1513
+ const VERB_CONJUGATION_REGEX =
1514
+ /(?: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;
1515
+
1516
+ // 2. Check Conjunction 'ki' erroneously attached to verbs
1517
+ if (!flagged && lower.endsWith("ki") && lower.length > 3) {
1518
+ const base = lower.slice(0, -2);
1519
+ if (!SOMBAHCEMI.has(lower)) {
1520
+ if (!(await this.isHeadword(lower))) {
1521
+ const root = await this.findRoot(base);
1522
+ 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);
1529
+
1530
+ if (isVerb) {
1531
+ issues.push({
1532
+ type: "conjunction_ki",
1533
+ word: rawWord,
1534
+ startIndex,
1535
+ endIndex,
1536
+ suggestion: `${base} ki`,
1537
+ message: `'ki' bağlacı ayrı yazılmalıdır.`,
1538
+ });
1539
+ flagged = true;
1540
+ }
1541
+ }
1542
+ }
1543
+ }
1544
+
1545
+ // 3. Check Conjunction 'da/de/ta/te' erroneously attached to verbs
1546
+ if (!flagged && (lower.endsWith("de") || lower.endsWith("da") || lower.endsWith("te") || lower.endsWith("ta")) && lower.length > 3) {
1547
+ const base = lower.slice(0, -2);
1548
+ const ending = lower.slice(-2);
1549
+ if (!(await this.isHeadword(lower))) {
1550
+ const root = await this.findRoot(base);
1551
+ const isVerb =
1552
+ (root && (root.endsWith("mek") || root.endsWith("mak"))) ||
1553
+ VERB_CONJUGATION_REGEX.test(base);
1554
+
1555
+ if (isVerb) {
1556
+ const correctEnding = ending.startsWith("t") ? (ending === "te" ? "de" : "da") : ending;
1557
+ issues.push({
1558
+ type: "conjunction_da",
1559
+ word: rawWord,
1560
+ startIndex,
1561
+ endIndex,
1562
+ suggestion: `${base} ${correctEnding}`,
1563
+ message: `'da/de' bağlacı fiillerden sonra her zaman ayrı yazılır (bağlaç olan da/de sertleşmez).`,
1564
+ });
1565
+ flagged = true;
1566
+ }
1567
+ }
1568
+ }
1569
+
1570
+ // 4. General Spell Check
1571
+ if (!flagged) {
1572
+ const check = await this.checkSpelling(rawWord);
1573
+ if (!check.isCorrect) {
1574
+ issues.push({
1575
+ type: "spelling",
1576
+ word: rawWord,
1577
+ startIndex,
1578
+ endIndex,
1579
+ suggestion: check.suggestion,
1580
+ message: check.suggestion
1581
+ ? `'${rawWord}' yanlış yazılmış olabilir. Öneri: '${check.suggestion}'`
1582
+ : `'${rawWord}' sözlükte bulunamadı.`,
1583
+ });
1584
+ }
1585
+ }
1586
+ }
1587
+
1588
+ return {
1589
+ text,
1590
+ issues,
1591
+ isCorrect: issues.length === 0,
1592
+ };
1593
+ }
1594
+ }
1595
+
1596
+ /**
1597
+ * Configurable instance-based client for TDK API.
1598
+ * Useful for multi-tenant applications or backend services requiring isolated configurations.
1599
+ */
1600
+ export class TDKClient {
1601
+ constructor(config?: TDKConfig) {
1602
+ if (config) {
1603
+ TDK.configure(config);
1604
+ }
1605
+ }
1606
+
1607
+ public getWord(word: string): Promise<WordInfo[]> {
1608
+ return TDK.getWord(word);
1609
+ }
1610
+
1611
+ public getMeanings(word: string): Promise<string[]> {
1612
+ return TDK.getMeanings(word);
1613
+ }
1614
+
1615
+ public checkSpelling(word: string): Promise<SpellCheckResult> {
1616
+ return TDK.checkSpelling(word);
1617
+ }
1618
+
1619
+ public findRoot(word: string): Promise<string | null> {
1620
+ return TDK.findRoot(word);
1621
+ }
1622
+
1623
+ public stem(word: string): Promise<StemResult | null> {
1624
+ return TDK.stem(word);
1625
+ }
1626
+
1627
+ public proofread(text: string): Promise<ProofreadResult> {
1628
+ return TDK.proofread(text);
1629
+ }
1630
+
1631
+ public patternSearch(pattern: string, options?: PatternSearchOptions): Promise<string[]> {
1632
+ return TDK.patternSearch(pattern, options);
1633
+ }
1634
+
1635
+ public findAnagrams(letters: string, options?: AnagramOptions): Promise<string[]> {
1636
+ return TDK.findAnagrams(letters, options);
1637
+ }
1638
+
1639
+ public findRhymes(word: string, options?: RhymeOptions): Promise<string[]> {
1640
+ return TDK.findRhymes(word, options);
1641
+ }
1642
+
1643
+ public syllabicate(word: string): string[] {
1644
+ return TDK.syllabicate(word);
1645
+ }
1646
+
1647
+ public checkVowelHarmony(word: string): boolean {
1648
+ return TDK.checkVowelHarmony(word);
1649
+ }
1650
+
1651
+ public checkLabialHarmony(word: string): boolean {
1652
+ return TDK.checkLabialHarmony(word);
1653
+ }
1110
1654
  }
1655
+