iraqi-dialect-bad-words 1.1.0 → 1.2.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/README.md CHANGED
@@ -64,6 +64,7 @@ Creates a filter with the built-in word list.
64
64
  ```ts
65
65
  new IraqiBadWordsFilter(); // profanity only
66
66
  new IraqiBadWordsFilter({ includeInsults: true }); // also milder insults
67
+ new IraqiBadWordsFilter({ stripSuffixes: true }); // also unlisted suffix forms
67
68
  new IraqiBadWordsFilter({ customWords: ['word1', 'word2'] });
68
69
  new IraqiBadWordsFilter(['word1', 'word2']); // same as customWords
69
70
  ```
@@ -72,6 +73,7 @@ new IraqiBadWordsFilter(['word1', 'word2']); // same as customWords
72
73
  |---|---|---|
73
74
  | `customWords` | `[]` | Extra words to filter. |
74
75
  | `includeInsults` | `false` | Also filter milder insults like كلب, حمار, قندرة, نعال. Off by default because many of them are everyday words, so turning it on flags sentences like `عندي كلب` ("I have a dog"). |
76
+ | `stripSuffixes` | `false` | Also match words with a pronoun suffix (ي ك ج ه ها نا كم هم) that the list doesn't spell out, e.g. `طيزهم` → `طيز`. Off by default because it widens matching: a clean word that ends like a bad word plus a suffix gets flagged too. |
75
77
 
76
78
  ### `isProfane(text: string): boolean`
77
79
 
@@ -81,6 +83,36 @@ Returns `true` if any word in the text is on the list.
81
83
 
82
84
  Replaces each bad word with the placeholder, repeated to the word's length. Spaces and punctuation are kept.
83
85
 
86
+ ### `inspect(text: string, options?): ProfanityMatch[]`
87
+
88
+ Lists each bad word in the text and explains why it matched. Use it to highlight words, to treat lists differently (e.g. block `words` but only warn on `insults`), or to let someone review a match before your app blocks it.
89
+
90
+ ```ts
91
+ filter.inspect('روح للكواد!');
92
+ // [{ token: 'للكواد', start: 4, end: 10, normalized: 'لكواد',
93
+ // entry: 'كواد', source: 'words', prefix: 'ل' }]
94
+ ```
95
+
96
+ | Field | Description |
97
+ |---|---|
98
+ | `token` | The word exactly as written. |
99
+ | `start`, `end` | Offsets in the **original** text, so `text.slice(start, end) === token`. Counted in JavaScript string units (UTF-16); convert them if another platform counts characters differently. |
100
+ | `normalized` | The word after normalization (Arabizi is folded too, so `Qa7ba` shows as `gahba`). |
101
+ | `entry` | The list entry it matched, spelled as in the list. |
102
+ | `source` | `'words'`, `'insults'` or `'custom'`. A word on more than one list keeps the first: built-in, then insults, then custom. |
103
+ | `prefix` | The attached prefix stripped before matching (after normalization, so `لل` shows as `ل`), or `''` for a direct match. |
104
+ | `suffix` | The suffix stripped before matching (only with `stripSuffixes`), or `''`. |
105
+ | `suppressedBy` | Only with `includeSuppressed`: the safe stem that cleared this match. |
106
+
107
+ Pass `{ includeSuppressed: true }` to also see words that matched but were cleared by a safe stem. They are not profane (`isProfane` and `clean` ignore them), but they show why a word *wasn't* flagged:
108
+
109
+ ```ts
110
+ filter.inspect('وبعيرك', { includeSuppressed: true });
111
+ // [{ token: 'وبعيرك', …, entry: 'عيرك', prefix: 'وب', suppressedBy: 'بعير' }]
112
+ ```
113
+
114
+ Including the output of `inspect` in a [“Clean word flagged” issue](https://github.com/karam-fikre/iraqi-dialect-bad-words/issues/new?template=false-positive.yml) shows exactly which entry and prefix rule fired.
115
+
84
116
  ### `addWords(words: string[]): void`
85
117
 
86
118
  Adds words to this filter.
@@ -100,8 +132,9 @@ Matching is done per whole word, after normalizing both the text and the word li
100
132
  | Tatweel | `عـــارة` |
101
133
  | ة / ه, ى / ي, أ / إ / آ / ا | `عاره` |
102
134
  | Attached prefixes و ف ب ل ك ال | `والعارة`, `للعارة` |
135
+ | Pronoun suffixes (with `stripSuffixes`) | `طيزهم`, `وطيزكم` |
103
136
  | Punctuation next to the word | `عارة!` `عارة،` |
104
- | Arabizi (Latin letters and numbers) | `ga7ba`, `3ayri` |
137
+ | Arabizi (Latin letters and numbers), including spelling variants | `ga7ba` = `qahba` = `gahba`, `kosomak` = `kusumak`, `3ayri` = `ayri` |
105
138
 
106
139
  Because matching is whole-word, a clean word that merely contains a bad word is not flagged.
107
140
 
@@ -119,6 +152,8 @@ The word list is the heart of this package, and native speakers are the best jud
119
152
  - **A clean word is flagged:** open a [“Clean word flagged” issue](https://github.com/karam-fikre/iraqi-dialect-bad-words/issues/new?template=false-positive.yml).
120
153
  If it's flagged only because of a prefix (e.g. `بعير` "camel" = `ب` + a bad word), the fix is adding it to [`src/safe-stems.json`](src/safe-stems.json).
121
154
 
155
+ - **A sentence is judged wrong:** add it to [`test/fixtures/sentences.json`](test/fixtures/sentences.json) with the verdict a native speaker expects (`"profane": true` or `false`, plus `"options"` if it needs `includeInsults` or `stripSuffixes`). Every sentence there runs as a test, so later word-list changes can't silently flip it.
156
+
122
157
  Words that also have a common, normal meaning (like كلب "dog") belong in [`src/insults.json`](src/insults.json), which is opt-in, not in `words.json`.
123
158
 
124
159
  ### Development
package/dist/index.d.mts CHANGED
@@ -6,10 +6,53 @@ interface IraqiBadWordsFilterOptions {
6
6
  * many of them are everyday words ("عندي كلب", "جلب لي هدية").
7
7
  */
8
8
  includeInsults?: boolean;
9
+ /**
10
+ * Also match words with a pronoun suffix the list doesn't spell out
11
+ * (طيزهم -> طيز). Off by default: it widens matching, so it can flag
12
+ * clean words that happen to end like a bad word plus a suffix.
13
+ */
14
+ stripSuffixes?: boolean;
15
+ }
16
+ /** Which list a dictionary entry came from. */
17
+ type WordSource = "words" | "insults" | "custom";
18
+ interface InspectOptions {
19
+ /**
20
+ * Also return words that matched but were cleared by a safe stem
21
+ * (e.g. بعير "camel" = ب + عير). They carry `suppressedBy`.
22
+ */
23
+ includeSuppressed?: boolean;
24
+ }
25
+ /** A bad word found by inspect(). */
26
+ interface ProfanityMatch {
27
+ /** The word exactly as written in the input. */
28
+ token: string;
29
+ /**
30
+ * Offset of the word in the input; text.slice(start, end) === token.
31
+ * Counted in JavaScript string units (UTF-16), like String.prototype.slice.
32
+ */
33
+ start: number;
34
+ /** Offset just past the end of the word in the input. */
35
+ end: number;
36
+ /** The word after normalization (diacritics, stretched letters, … removed). */
37
+ normalized: string;
38
+ /** The dictionary entry it matched, spelled as in its list. */
39
+ entry: string;
40
+ /** The list the entry came from. */
41
+ source: WordSource;
42
+ /** The prefix stripped before matching (e.g. "وال"), or "" for a direct match. */
43
+ prefix: string;
44
+ /** The suffix stripped before matching (only with stripSuffixes), or "". */
45
+ suffix: string;
46
+ /**
47
+ * The safe stem that cleared this match, spelled as in safe-stems.json.
48
+ * Only set on matches returned with `includeSuppressed`; these are not profane.
49
+ */
50
+ suppressedBy?: string;
9
51
  }
10
52
  declare class IraqiBadWordsFilter {
11
53
  private words;
12
54
  private safeStems;
55
+ private stripSuffixes;
13
56
  constructor(options?: string[] | IraqiBadWordsFilterOptions);
14
57
  /**
15
58
  * Normalizes Arabic text so spelling variants compare equal:
@@ -17,15 +60,37 @@ declare class IraqiBadWordsFilter {
17
60
  * forms, and collapses stretched letters (عاااارة -> عارة).
18
61
  */
19
62
  private normalizeArabic;
63
+ /**
64
+ * Folds Arabizi (Arabic written in Latin letters and digits) so spelling
65
+ * variants compare equal: ga7ba / qahba / gahba, kosomak / kusomak,
66
+ * teezak / tizak. Only words made purely of Latin letters and digits are
67
+ * folded; expects text already passed through normalizeArabic.
68
+ */
69
+ private foldArabizi;
70
+ /** Normalizes a word or token for comparison. */
71
+ private normalize;
72
+ /**
73
+ * Adds words from one list. A word already present keeps its first source.
74
+ */
75
+ private addEntries;
20
76
  /**
21
77
  * Checks a single word, also trying it with attached prefixes removed
22
- * (والكحبة -> كحبة, بطيزك -> طيزك).
78
+ * (والكحبة -> كحبة, بطيزك -> طيزك), and suffixes removed when stripSuffixes is on.
79
+ * Returns the matched entry, or null.
80
+ * A match cleared by a safe stem is returned with `suppressedBy` set.
23
81
  */
82
+ private matchWord;
83
+ /** True when the word is profane, i.e. matched and not cleared by a safe stem. */
24
84
  private isProfaneWord;
25
85
  /**
26
86
  * Checks if a given text contains profane words.
27
87
  */
28
88
  isProfane(text: string): boolean;
89
+ /**
90
+ * Lists every bad word in the text with its position in the original input
91
+ * and why it matched, e.g. to highlight words or review a match before blocking.
92
+ */
93
+ inspect(text: string, options?: InspectOptions): ProfanityMatch[];
29
94
  /**
30
95
  * Replaces profane words with a placeholder string.
31
96
  */
@@ -40,4 +105,4 @@ declare class IraqiBadWordsFilter {
40
105
  removeWords(wordsToRemove: string[]): void;
41
106
  }
42
107
 
43
- export { IraqiBadWordsFilter, type IraqiBadWordsFilterOptions, IraqiBadWordsFilter as default };
108
+ export { type InspectOptions, IraqiBadWordsFilter, type IraqiBadWordsFilterOptions, type ProfanityMatch, type WordSource, IraqiBadWordsFilter as default };
package/dist/index.d.ts CHANGED
@@ -6,10 +6,53 @@ interface IraqiBadWordsFilterOptions {
6
6
  * many of them are everyday words ("عندي كلب", "جلب لي هدية").
7
7
  */
8
8
  includeInsults?: boolean;
9
+ /**
10
+ * Also match words with a pronoun suffix the list doesn't spell out
11
+ * (طيزهم -> طيز). Off by default: it widens matching, so it can flag
12
+ * clean words that happen to end like a bad word plus a suffix.
13
+ */
14
+ stripSuffixes?: boolean;
15
+ }
16
+ /** Which list a dictionary entry came from. */
17
+ type WordSource = "words" | "insults" | "custom";
18
+ interface InspectOptions {
19
+ /**
20
+ * Also return words that matched but were cleared by a safe stem
21
+ * (e.g. بعير "camel" = ب + عير). They carry `suppressedBy`.
22
+ */
23
+ includeSuppressed?: boolean;
24
+ }
25
+ /** A bad word found by inspect(). */
26
+ interface ProfanityMatch {
27
+ /** The word exactly as written in the input. */
28
+ token: string;
29
+ /**
30
+ * Offset of the word in the input; text.slice(start, end) === token.
31
+ * Counted in JavaScript string units (UTF-16), like String.prototype.slice.
32
+ */
33
+ start: number;
34
+ /** Offset just past the end of the word in the input. */
35
+ end: number;
36
+ /** The word after normalization (diacritics, stretched letters, … removed). */
37
+ normalized: string;
38
+ /** The dictionary entry it matched, spelled as in its list. */
39
+ entry: string;
40
+ /** The list the entry came from. */
41
+ source: WordSource;
42
+ /** The prefix stripped before matching (e.g. "وال"), or "" for a direct match. */
43
+ prefix: string;
44
+ /** The suffix stripped before matching (only with stripSuffixes), or "". */
45
+ suffix: string;
46
+ /**
47
+ * The safe stem that cleared this match, spelled as in safe-stems.json.
48
+ * Only set on matches returned with `includeSuppressed`; these are not profane.
49
+ */
50
+ suppressedBy?: string;
9
51
  }
10
52
  declare class IraqiBadWordsFilter {
11
53
  private words;
12
54
  private safeStems;
55
+ private stripSuffixes;
13
56
  constructor(options?: string[] | IraqiBadWordsFilterOptions);
14
57
  /**
15
58
  * Normalizes Arabic text so spelling variants compare equal:
@@ -17,15 +60,37 @@ declare class IraqiBadWordsFilter {
17
60
  * forms, and collapses stretched letters (عاااارة -> عارة).
18
61
  */
19
62
  private normalizeArabic;
63
+ /**
64
+ * Folds Arabizi (Arabic written in Latin letters and digits) so spelling
65
+ * variants compare equal: ga7ba / qahba / gahba, kosomak / kusomak,
66
+ * teezak / tizak. Only words made purely of Latin letters and digits are
67
+ * folded; expects text already passed through normalizeArabic.
68
+ */
69
+ private foldArabizi;
70
+ /** Normalizes a word or token for comparison. */
71
+ private normalize;
72
+ /**
73
+ * Adds words from one list. A word already present keeps its first source.
74
+ */
75
+ private addEntries;
20
76
  /**
21
77
  * Checks a single word, also trying it with attached prefixes removed
22
- * (والكحبة -> كحبة, بطيزك -> طيزك).
78
+ * (والكحبة -> كحبة, بطيزك -> طيزك), and suffixes removed when stripSuffixes is on.
79
+ * Returns the matched entry, or null.
80
+ * A match cleared by a safe stem is returned with `suppressedBy` set.
23
81
  */
82
+ private matchWord;
83
+ /** True when the word is profane, i.e. matched and not cleared by a safe stem. */
24
84
  private isProfaneWord;
25
85
  /**
26
86
  * Checks if a given text contains profane words.
27
87
  */
28
88
  isProfane(text: string): boolean;
89
+ /**
90
+ * Lists every bad word in the text with its position in the original input
91
+ * and why it matched, e.g. to highlight words or review a match before blocking.
92
+ */
93
+ inspect(text: string, options?: InspectOptions): ProfanityMatch[];
29
94
  /**
30
95
  * Replaces profane words with a placeholder string.
31
96
  */
@@ -40,4 +105,4 @@ declare class IraqiBadWordsFilter {
40
105
  removeWords(wordsToRemove: string[]): void;
41
106
  }
42
107
 
43
- export { IraqiBadWordsFilter, type IraqiBadWordsFilterOptions, IraqiBadWordsFilter as default };
108
+ export { type InspectOptions, IraqiBadWordsFilter, type IraqiBadWordsFilterOptions, type ProfanityMatch, type WordSource, IraqiBadWordsFilter as default };
package/dist/index.js CHANGED
@@ -41,6 +41,12 @@ var words_default = [
41
41
  "\u0639\u064A\u0631\u0647",
42
42
  "\u0639\u064A\u0631\u0647\u0627",
43
43
  "\u0639\u064A\u0631\u064A\u0646",
44
+ "\u0639\u064A\u063A",
45
+ "\u0639\u064A\u063A\u064A",
46
+ "\u0639\u064A\u063A\u0643",
47
+ "\u0639\u064A\u063A\u0647",
48
+ "\u0639\u064A\u063A\u0647\u0627",
49
+ "\u0639\u064A\u063A\u064A\u0646",
44
50
  "\u0632\u0628",
45
51
  "\u0632\u0628\u064A",
46
52
  "\u0632\u0628\u0643",
@@ -226,6 +232,7 @@ var insults_default = [
226
232
  // src/safe-stems.json
227
233
  var safe_stems_default = [
228
234
  "\u0628\u0639\u064A\u0631",
235
+ "\u0628\u0639\u064A\u063A",
229
236
  "\u0628\u0643\u0633",
230
237
  "\u0648\u0643\u0633",
231
238
  "\u0641\u0643\u0633"
@@ -236,12 +243,16 @@ var WORD_PATTERN = /[A-Za-z0-9_À-ɏ؀-؋؍-ؚ؜-؞ؠ-٩ٮ-ۓە-ۿݐ-ݿࢠ-ࣿ
236
243
  var CONJUNCTIONS = ["", "\u0648", "\u0641"];
237
244
  var PREPOSITIONS = ["", "\u0628", "\u0644", "\u0643"];
238
245
  var ARTICLES = ["", "\u0627\u0644"];
246
+ var SUFFIXES = ["\u064A", "\u0643", "\u062C", "\u0647", "\u0647\u0627", "\u0646\u0627", "\u0643\u0645", "\u0647\u0645"];
239
247
  var IraqiBadWordsFilter = class {
240
248
  constructor(options = {}) {
241
- const { customWords = [], includeInsults = false } = Array.isArray(options) ? { customWords: options } : options;
242
- this.words = /* @__PURE__ */ new Set();
243
- this.safeStems = safe_stems_default.map((stem) => this.normalizeArabic(stem));
244
- this.addWords([...words_default, ...includeInsults ? insults_default : [], ...customWords]);
249
+ const { customWords = [], includeInsults = false, stripSuffixes = false } = Array.isArray(options) ? { customWords: options } : options;
250
+ this.words = /* @__PURE__ */ new Map();
251
+ this.stripSuffixes = stripSuffixes;
252
+ this.safeStems = safe_stems_default.map((word) => ({ normalized: this.normalize(word), word }));
253
+ this.addEntries(words_default, "words");
254
+ if (includeInsults) this.addEntries(insults_default, "insults");
255
+ this.addEntries(customWords, "custom");
245
256
  }
246
257
  /**
247
258
  * Normalizes Arabic text so spelling variants compare equal:
@@ -249,30 +260,83 @@ var IraqiBadWordsFilter = class {
249
260
  * forms, and collapses stretched letters (عاااارة -> عارة).
250
261
  */
251
262
  normalizeArabic(text) {
252
- return text.replace(/[ً-ٰٟـ]/g, "").replace(/[أإآٱ]/g, "\u0627").replace(/ة/g, "\u0647").replace(/ى/g, "\u064A").replace(/(.)\1+/gu, "$1").toLowerCase();
263
+ return text.replace(/[ً-ٰٟـ]/g, "").replace(/[أإآٱ]/g, "\u0627").replace(/ة/g, "\u0647").replace(/ى/g, "\u064A").replace(/(.)\1+/gu, "$1").toLowerCase();
264
+ }
265
+ /**
266
+ * Folds Arabizi (Arabic written in Latin letters and digits) so spelling
267
+ * variants compare equal: ga7ba / qahba / gahba, kosomak / kusomak,
268
+ * teezak / tizak. Only words made purely of Latin letters and digits are
269
+ * folded; expects text already passed through normalizeArabic.
270
+ */
271
+ foldArabizi(word) {
272
+ if (!/^[a-z0-9]+$/.test(word) || !/[a-z]/.test(word)) return word;
273
+ return word.replace(/7/g, "h").replace(/5/g, "kh").replace(/[23]/g, "").replace(/q/g, "g").replace(/ou|o/g, "u").replace(/e/g, "i").replace(/(.)\1+/g, "$1");
274
+ }
275
+ /** Normalizes a word or token for comparison. */
276
+ normalize(text) {
277
+ return this.foldArabizi(this.normalizeArabic(text));
278
+ }
279
+ /**
280
+ * Adds words from one list. A word already present keeps its first source.
281
+ */
282
+ addEntries(newWords, source) {
283
+ for (const word of newWords) {
284
+ const normalized = this.normalize(word);
285
+ if (!this.words.has(normalized)) this.words.set(normalized, { word, source });
286
+ }
253
287
  }
254
288
  /**
255
289
  * Checks a single word, also trying it with attached prefixes removed
256
- * (والكحبة -> كحبة, بطيزك -> طيزك).
290
+ * (والكحبة -> كحبة, بطيزك -> طيزك), and suffixes removed when stripSuffixes is on.
291
+ * Returns the matched entry, or null.
292
+ * A match cleared by a safe stem is returned with `suppressedBy` set.
257
293
  */
258
- isProfaneWord(token) {
259
- const word = this.normalizeArabic(token);
260
- if (this.words.has(word)) return true;
261
- const stems = [word];
294
+ matchWord(token) {
295
+ const word = this.normalize(token);
296
+ const direct = this.words.get(word);
297
+ if (direct) {
298
+ return { normalized: word, entry: direct.word, source: direct.source, prefix: "", suffix: "" };
299
+ }
300
+ const candidates = [{ prefix: "", stem: word, suffix: "" }];
262
301
  for (const conjunction of CONJUNCTIONS) {
263
302
  for (const preposition of PREPOSITIONS) {
264
303
  for (const article of ARTICLES) {
265
304
  const prefix = conjunction + preposition + article;
266
305
  if (prefix && word.startsWith(prefix) && word.length - prefix.length >= 2) {
267
- stems.push(word.slice(prefix.length));
306
+ candidates.push({ prefix, stem: word.slice(prefix.length), suffix: "" });
307
+ }
308
+ }
309
+ }
310
+ }
311
+ if (this.stripSuffixes) {
312
+ for (const { prefix, stem } of [...candidates]) {
313
+ for (const suffix of SUFFIXES) {
314
+ if (stem.endsWith(suffix) && stem.length - suffix.length >= 2) {
315
+ candidates.push({ prefix, stem: stem.slice(0, -suffix.length), suffix });
268
316
  }
269
317
  }
270
318
  }
271
319
  }
272
- if (stems.some((stem) => this.safeStems.some((safe) => stem.startsWith(safe)))) {
273
- return false;
320
+ const match = candidates.find(({ stem }) => this.words.has(stem));
321
+ if (!match) return null;
322
+ const { word: entry, source } = this.words.get(match.stem);
323
+ const result = {
324
+ normalized: word,
325
+ entry,
326
+ source,
327
+ prefix: match.prefix,
328
+ suffix: match.suffix
329
+ };
330
+ for (const { stem } of candidates) {
331
+ const safe = this.safeStems.find(({ normalized }) => stem.startsWith(normalized));
332
+ if (safe) return { ...result, suppressedBy: safe.word };
274
333
  }
275
- return stems.some((stem) => this.words.has(stem));
334
+ return result;
335
+ }
336
+ /** True when the word is profane, i.e. matched and not cleared by a safe stem. */
337
+ isProfaneWord(token) {
338
+ const match = this.matchWord(token);
339
+ return match !== null && match.suppressedBy === void 0;
276
340
  }
277
341
  /**
278
342
  * Checks if a given text contains profane words.
@@ -286,27 +350,48 @@ var IraqiBadWordsFilter = class {
286
350
  }
287
351
  return false;
288
352
  }
353
+ /**
354
+ * Lists every bad word in the text with its position in the original input
355
+ * and why it matched, e.g. to highlight words or review a match before blocking.
356
+ */
357
+ inspect(text, options = {}) {
358
+ if (!text) return [];
359
+ const matches = [];
360
+ const pattern = new RegExp(WORD_PATTERN.source, "g");
361
+ let found;
362
+ while (found = pattern.exec(text)) {
363
+ const token = found[0];
364
+ const match = this.matchWord(token);
365
+ if (match && (match.suppressedBy === void 0 || options.includeSuppressed)) {
366
+ matches.push({ token, start: found.index, end: found.index + token.length, ...match });
367
+ }
368
+ }
369
+ return matches;
370
+ }
289
371
  /**
290
372
  * Replaces profane words with a placeholder string.
291
373
  */
292
374
  clean(text, placeholder = "*") {
293
375
  if (!text) return "";
294
- return text.replace(
295
- WORD_PATTERN,
296
- (token) => this.isProfaneWord(token) ? placeholder.repeat(token.length) : token
297
- );
376
+ let result = "";
377
+ let last = 0;
378
+ for (const { start, end } of this.inspect(text)) {
379
+ result += text.slice(last, start) + placeholder.repeat(end - start);
380
+ last = end;
381
+ }
382
+ return result + text.slice(last);
298
383
  }
299
384
  /**
300
385
  * Dynamically add words to the active dictionary.
301
386
  */
302
387
  addWords(newWords) {
303
- newWords.forEach((word) => this.words.add(this.normalizeArabic(word)));
388
+ this.addEntries(newWords, "custom");
304
389
  }
305
390
  /**
306
391
  * Remove words from the active filter dictionary.
307
392
  */
308
393
  removeWords(wordsToRemove) {
309
- wordsToRemove.forEach((word) => this.words.delete(this.normalizeArabic(word)));
394
+ wordsToRemove.forEach((word) => this.words.delete(this.normalize(word)));
310
395
  }
311
396
  };
312
397
  var index_default = IraqiBadWordsFilter;
package/dist/index.mjs CHANGED
@@ -14,6 +14,12 @@ var words_default = [
14
14
  "\u0639\u064A\u0631\u0647",
15
15
  "\u0639\u064A\u0631\u0647\u0627",
16
16
  "\u0639\u064A\u0631\u064A\u0646",
17
+ "\u0639\u064A\u063A",
18
+ "\u0639\u064A\u063A\u064A",
19
+ "\u0639\u064A\u063A\u0643",
20
+ "\u0639\u064A\u063A\u0647",
21
+ "\u0639\u064A\u063A\u0647\u0627",
22
+ "\u0639\u064A\u063A\u064A\u0646",
17
23
  "\u0632\u0628",
18
24
  "\u0632\u0628\u064A",
19
25
  "\u0632\u0628\u0643",
@@ -199,6 +205,7 @@ var insults_default = [
199
205
  // src/safe-stems.json
200
206
  var safe_stems_default = [
201
207
  "\u0628\u0639\u064A\u0631",
208
+ "\u0628\u0639\u064A\u063A",
202
209
  "\u0628\u0643\u0633",
203
210
  "\u0648\u0643\u0633",
204
211
  "\u0641\u0643\u0633"
@@ -209,12 +216,16 @@ var WORD_PATTERN = /[A-Za-z0-9_À-ɏ؀-؋؍-ؚ؜-؞ؠ-٩ٮ-ۓە-ۿݐ-ݿࢠ-ࣿ
209
216
  var CONJUNCTIONS = ["", "\u0648", "\u0641"];
210
217
  var PREPOSITIONS = ["", "\u0628", "\u0644", "\u0643"];
211
218
  var ARTICLES = ["", "\u0627\u0644"];
219
+ var SUFFIXES = ["\u064A", "\u0643", "\u062C", "\u0647", "\u0647\u0627", "\u0646\u0627", "\u0643\u0645", "\u0647\u0645"];
212
220
  var IraqiBadWordsFilter = class {
213
221
  constructor(options = {}) {
214
- const { customWords = [], includeInsults = false } = Array.isArray(options) ? { customWords: options } : options;
215
- this.words = /* @__PURE__ */ new Set();
216
- this.safeStems = safe_stems_default.map((stem) => this.normalizeArabic(stem));
217
- this.addWords([...words_default, ...includeInsults ? insults_default : [], ...customWords]);
222
+ const { customWords = [], includeInsults = false, stripSuffixes = false } = Array.isArray(options) ? { customWords: options } : options;
223
+ this.words = /* @__PURE__ */ new Map();
224
+ this.stripSuffixes = stripSuffixes;
225
+ this.safeStems = safe_stems_default.map((word) => ({ normalized: this.normalize(word), word }));
226
+ this.addEntries(words_default, "words");
227
+ if (includeInsults) this.addEntries(insults_default, "insults");
228
+ this.addEntries(customWords, "custom");
218
229
  }
219
230
  /**
220
231
  * Normalizes Arabic text so spelling variants compare equal:
@@ -222,30 +233,83 @@ var IraqiBadWordsFilter = class {
222
233
  * forms, and collapses stretched letters (عاااارة -> عارة).
223
234
  */
224
235
  normalizeArabic(text) {
225
- return text.replace(/[ً-ٰٟـ]/g, "").replace(/[أإآٱ]/g, "\u0627").replace(/ة/g, "\u0647").replace(/ى/g, "\u064A").replace(/(.)\1+/gu, "$1").toLowerCase();
236
+ return text.replace(/[ً-ٰٟـ]/g, "").replace(/[أإآٱ]/g, "\u0627").replace(/ة/g, "\u0647").replace(/ى/g, "\u064A").replace(/(.)\1+/gu, "$1").toLowerCase();
237
+ }
238
+ /**
239
+ * Folds Arabizi (Arabic written in Latin letters and digits) so spelling
240
+ * variants compare equal: ga7ba / qahba / gahba, kosomak / kusomak,
241
+ * teezak / tizak. Only words made purely of Latin letters and digits are
242
+ * folded; expects text already passed through normalizeArabic.
243
+ */
244
+ foldArabizi(word) {
245
+ if (!/^[a-z0-9]+$/.test(word) || !/[a-z]/.test(word)) return word;
246
+ return word.replace(/7/g, "h").replace(/5/g, "kh").replace(/[23]/g, "").replace(/q/g, "g").replace(/ou|o/g, "u").replace(/e/g, "i").replace(/(.)\1+/g, "$1");
247
+ }
248
+ /** Normalizes a word or token for comparison. */
249
+ normalize(text) {
250
+ return this.foldArabizi(this.normalizeArabic(text));
251
+ }
252
+ /**
253
+ * Adds words from one list. A word already present keeps its first source.
254
+ */
255
+ addEntries(newWords, source) {
256
+ for (const word of newWords) {
257
+ const normalized = this.normalize(word);
258
+ if (!this.words.has(normalized)) this.words.set(normalized, { word, source });
259
+ }
226
260
  }
227
261
  /**
228
262
  * Checks a single word, also trying it with attached prefixes removed
229
- * (والكحبة -> كحبة, بطيزك -> طيزك).
263
+ * (والكحبة -> كحبة, بطيزك -> طيزك), and suffixes removed when stripSuffixes is on.
264
+ * Returns the matched entry, or null.
265
+ * A match cleared by a safe stem is returned with `suppressedBy` set.
230
266
  */
231
- isProfaneWord(token) {
232
- const word = this.normalizeArabic(token);
233
- if (this.words.has(word)) return true;
234
- const stems = [word];
267
+ matchWord(token) {
268
+ const word = this.normalize(token);
269
+ const direct = this.words.get(word);
270
+ if (direct) {
271
+ return { normalized: word, entry: direct.word, source: direct.source, prefix: "", suffix: "" };
272
+ }
273
+ const candidates = [{ prefix: "", stem: word, suffix: "" }];
235
274
  for (const conjunction of CONJUNCTIONS) {
236
275
  for (const preposition of PREPOSITIONS) {
237
276
  for (const article of ARTICLES) {
238
277
  const prefix = conjunction + preposition + article;
239
278
  if (prefix && word.startsWith(prefix) && word.length - prefix.length >= 2) {
240
- stems.push(word.slice(prefix.length));
279
+ candidates.push({ prefix, stem: word.slice(prefix.length), suffix: "" });
280
+ }
281
+ }
282
+ }
283
+ }
284
+ if (this.stripSuffixes) {
285
+ for (const { prefix, stem } of [...candidates]) {
286
+ for (const suffix of SUFFIXES) {
287
+ if (stem.endsWith(suffix) && stem.length - suffix.length >= 2) {
288
+ candidates.push({ prefix, stem: stem.slice(0, -suffix.length), suffix });
241
289
  }
242
290
  }
243
291
  }
244
292
  }
245
- if (stems.some((stem) => this.safeStems.some((safe) => stem.startsWith(safe)))) {
246
- return false;
293
+ const match = candidates.find(({ stem }) => this.words.has(stem));
294
+ if (!match) return null;
295
+ const { word: entry, source } = this.words.get(match.stem);
296
+ const result = {
297
+ normalized: word,
298
+ entry,
299
+ source,
300
+ prefix: match.prefix,
301
+ suffix: match.suffix
302
+ };
303
+ for (const { stem } of candidates) {
304
+ const safe = this.safeStems.find(({ normalized }) => stem.startsWith(normalized));
305
+ if (safe) return { ...result, suppressedBy: safe.word };
247
306
  }
248
- return stems.some((stem) => this.words.has(stem));
307
+ return result;
308
+ }
309
+ /** True when the word is profane, i.e. matched and not cleared by a safe stem. */
310
+ isProfaneWord(token) {
311
+ const match = this.matchWord(token);
312
+ return match !== null && match.suppressedBy === void 0;
249
313
  }
250
314
  /**
251
315
  * Checks if a given text contains profane words.
@@ -259,27 +323,48 @@ var IraqiBadWordsFilter = class {
259
323
  }
260
324
  return false;
261
325
  }
326
+ /**
327
+ * Lists every bad word in the text with its position in the original input
328
+ * and why it matched, e.g. to highlight words or review a match before blocking.
329
+ */
330
+ inspect(text, options = {}) {
331
+ if (!text) return [];
332
+ const matches = [];
333
+ const pattern = new RegExp(WORD_PATTERN.source, "g");
334
+ let found;
335
+ while (found = pattern.exec(text)) {
336
+ const token = found[0];
337
+ const match = this.matchWord(token);
338
+ if (match && (match.suppressedBy === void 0 || options.includeSuppressed)) {
339
+ matches.push({ token, start: found.index, end: found.index + token.length, ...match });
340
+ }
341
+ }
342
+ return matches;
343
+ }
262
344
  /**
263
345
  * Replaces profane words with a placeholder string.
264
346
  */
265
347
  clean(text, placeholder = "*") {
266
348
  if (!text) return "";
267
- return text.replace(
268
- WORD_PATTERN,
269
- (token) => this.isProfaneWord(token) ? placeholder.repeat(token.length) : token
270
- );
349
+ let result = "";
350
+ let last = 0;
351
+ for (const { start, end } of this.inspect(text)) {
352
+ result += text.slice(last, start) + placeholder.repeat(end - start);
353
+ last = end;
354
+ }
355
+ return result + text.slice(last);
271
356
  }
272
357
  /**
273
358
  * Dynamically add words to the active dictionary.
274
359
  */
275
360
  addWords(newWords) {
276
- newWords.forEach((word) => this.words.add(this.normalizeArabic(word)));
361
+ this.addEntries(newWords, "custom");
277
362
  }
278
363
  /**
279
364
  * Remove words from the active filter dictionary.
280
365
  */
281
366
  removeWords(wordsToRemove) {
282
- wordsToRemove.forEach((word) => this.words.delete(this.normalizeArabic(word)));
367
+ wordsToRemove.forEach((word) => this.words.delete(this.normalize(word)));
283
368
  }
284
369
  };
285
370
  var index_default = IraqiBadWordsFilter;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iraqi-dialect-bad-words",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "An Iraqi dialect profanity filter for React, React Native Expo, and Node.js",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",