iraqi-dialect-bad-words 1.1.0 → 1.2.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/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.
@@ -99,9 +131,13 @@ Matching is done per whole word, after normalizing both the text and the word li
99
131
  | Stretched letters | `عاااارة` |
100
132
  | Tatweel | `عـــارة` |
101
133
  | ة / ه, ى / ي, أ / إ / آ / ا | `عاره` |
134
+ | Persian/Kurdish keyboard letters ی ک ە | `عیر`, `کس` |
135
+ | Invisible characters hidden inside a word (zero-width, direction marks, soft hyphen) | `عا‌رة` |
102
136
  | Attached prefixes و ف ب ل ك ال | `والعارة`, `للعارة` |
137
+ | Pronoun suffixes (with `stripSuffixes`) | `طيزهم`, `وطيزكم` |
103
138
  | Punctuation next to the word | `عارة!` `عارة،` |
104
- | Arabizi (Latin letters and numbers) | `ga7ba`, `3ayri` |
139
+ | Digits glued to or inside an Arabic word | `عارة3`, `عا٣رة` |
140
+ | Arabizi (Latin letters and numbers, Western or Arabic-Indic), including spelling variants | `ga7ba` = `ga٧ba` = `qahba` = `gahba`, `kosomak` = `kusumak`, `3ayri` = `ayri` |
105
141
 
106
142
  Because matching is whole-word, a clean word that merely contains a bad word is not flagged.
107
143
 
@@ -119,6 +155,8 @@ The word list is the heart of this package, and native speakers are the best jud
119
155
  - **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
156
  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
157
 
158
+ - **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.
159
+
122
160
  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
161
 
124
162
  ### Development
package/dist/index.d.mts CHANGED
@@ -6,26 +6,98 @@ 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:
16
- * removes diacritics (Tashkeel) and tatweel, unifies Alef/Teh Marbuta/Alef Maksura
17
- * forms, and collapses stretched letters (عاااارة -> عارة).
59
+ * removes diacritics (Tashkeel), tatweel and invisible characters, unifies
60
+ * Alef/Teh Marbuta/Alef Maksura forms and the Persian/Kurdish keyboard letters
61
+ * ی ک ە, turns Arabic-Indic digits into 0-9, and collapses stretched letters
62
+ * (عاااارة -> عارة).
18
63
  */
19
64
  private normalizeArabic;
65
+ /**
66
+ * Folds Arabizi (Arabic written in Latin letters and digits) so spelling
67
+ * variants compare equal: ga7ba / qahba / gahba, kosomak / kusomak,
68
+ * teezak / tizak. Only words made purely of Latin letters and digits are
69
+ * folded; expects text already passed through normalizeArabic.
70
+ */
71
+ private foldArabizi;
72
+ /** Normalizes a word or token for comparison. */
73
+ private normalize;
74
+ /**
75
+ * Adds words from one list. A word already present keeps its first source.
76
+ */
77
+ private addEntries;
20
78
  /**
21
79
  * Checks a single word, also trying it with attached prefixes removed
22
- * (والكحبة -> كحبة, بطيزك -> طيزك).
80
+ * (والكحبة -> كحبة, بطيزك -> طيزك), and suffixes removed when stripSuffixes is on.
81
+ * Returns the matched entry, or null.
82
+ * A match cleared by a safe stem is returned with `suppressedBy` set.
23
83
  */
24
- private isProfaneWord;
84
+ private matchWord;
85
+ /**
86
+ * Finds every match in the text, including ones cleared by a safe stem.
87
+ * A token with invisible characters is matched whole first (عا‌رة -> عارة);
88
+ * if that finds nothing profane, each visible piece is tried on its own, so an
89
+ * invisible character between two words can't hide a bad word either.
90
+ */
91
+ private findMatches;
25
92
  /**
26
93
  * Checks if a given text contains profane words.
27
94
  */
28
95
  isProfane(text: string): boolean;
96
+ /**
97
+ * Lists every bad word in the text with its position in the original input
98
+ * and why it matched, e.g. to highlight words or review a match before blocking.
99
+ */
100
+ inspect(text: string, options?: InspectOptions): ProfanityMatch[];
29
101
  /**
30
102
  * Replaces profane words with a placeholder string.
31
103
  */
@@ -40,4 +112,4 @@ declare class IraqiBadWordsFilter {
40
112
  removeWords(wordsToRemove: string[]): void;
41
113
  }
42
114
 
43
- export { IraqiBadWordsFilter, type IraqiBadWordsFilterOptions, IraqiBadWordsFilter as default };
115
+ export { type InspectOptions, IraqiBadWordsFilter, type IraqiBadWordsFilterOptions, type ProfanityMatch, type WordSource, IraqiBadWordsFilter as default };
package/dist/index.d.ts CHANGED
@@ -6,26 +6,98 @@ 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:
16
- * removes diacritics (Tashkeel) and tatweel, unifies Alef/Teh Marbuta/Alef Maksura
17
- * forms, and collapses stretched letters (عاااارة -> عارة).
59
+ * removes diacritics (Tashkeel), tatweel and invisible characters, unifies
60
+ * Alef/Teh Marbuta/Alef Maksura forms and the Persian/Kurdish keyboard letters
61
+ * ی ک ە, turns Arabic-Indic digits into 0-9, and collapses stretched letters
62
+ * (عاااارة -> عارة).
18
63
  */
19
64
  private normalizeArabic;
65
+ /**
66
+ * Folds Arabizi (Arabic written in Latin letters and digits) so spelling
67
+ * variants compare equal: ga7ba / qahba / gahba, kosomak / kusomak,
68
+ * teezak / tizak. Only words made purely of Latin letters and digits are
69
+ * folded; expects text already passed through normalizeArabic.
70
+ */
71
+ private foldArabizi;
72
+ /** Normalizes a word or token for comparison. */
73
+ private normalize;
74
+ /**
75
+ * Adds words from one list. A word already present keeps its first source.
76
+ */
77
+ private addEntries;
20
78
  /**
21
79
  * Checks a single word, also trying it with attached prefixes removed
22
- * (والكحبة -> كحبة, بطيزك -> طيزك).
80
+ * (والكحبة -> كحبة, بطيزك -> طيزك), and suffixes removed when stripSuffixes is on.
81
+ * Returns the matched entry, or null.
82
+ * A match cleared by a safe stem is returned with `suppressedBy` set.
23
83
  */
24
- private isProfaneWord;
84
+ private matchWord;
85
+ /**
86
+ * Finds every match in the text, including ones cleared by a safe stem.
87
+ * A token with invisible characters is matched whole first (عا‌رة -> عارة);
88
+ * if that finds nothing profane, each visible piece is tried on its own, so an
89
+ * invisible character between two words can't hide a bad word either.
90
+ */
91
+ private findMatches;
25
92
  /**
26
93
  * Checks if a given text contains profane words.
27
94
  */
28
95
  isProfane(text: string): boolean;
96
+ /**
97
+ * Lists every bad word in the text with its position in the original input
98
+ * and why it matched, e.g. to highlight words or review a match before blocking.
99
+ */
100
+ inspect(text: string, options?: InspectOptions): ProfanityMatch[];
29
101
  /**
30
102
  * Replaces profane words with a placeholder string.
31
103
  */
@@ -40,4 +112,4 @@ declare class IraqiBadWordsFilter {
40
112
  removeWords(wordsToRemove: string[]): void;
41
113
  }
42
114
 
43
- export { IraqiBadWordsFilter, type IraqiBadWordsFilterOptions, IraqiBadWordsFilter as default };
115
+ 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,87 +232,193 @@ 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"
232
239
  ];
233
240
 
234
241
  // src/index.ts
235
- var WORD_PATTERN = /[A-Za-z0-9_À-ɏ؀-؋؍-ؚ؜-؞ؠ-٩ٮ-ۓە-ۿݐ-ݿࢠ-ࣿﭐ-﷿ﹰ-ﻼ]+/g;
242
+ var INVISIBLE = "\\u00AD\\u061C\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF";
243
+ var INVISIBLE_CHARS = new RegExp(`[${INVISIBLE}]`, "g");
244
+ var HAS_INVISIBLE = new RegExp(`[${INVISIBLE}]`);
245
+ var VISIBLE_RUN = new RegExp(`[^${INVISIBLE}]+`, "g");
246
+ var WORD_PATTERN = new RegExp(
247
+ `[A-Za-z0-9_\xC0-\u024F\u0600-\u060B\u060D-\u061A\u061C-\u061E\u0620-\u0669\u066E-\u06D3\u06D5-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFC${INVISIBLE}]+`,
248
+ "g"
249
+ );
236
250
  var CONJUNCTIONS = ["", "\u0648", "\u0641"];
237
251
  var PREPOSITIONS = ["", "\u0628", "\u0644", "\u0643"];
238
252
  var ARTICLES = ["", "\u0627\u0644"];
253
+ var SUFFIXES = ["\u064A", "\u0643", "\u062C", "\u0647", "\u0647\u0627", "\u0646\u0627", "\u0643\u0645", "\u0647\u0645"];
239
254
  var IraqiBadWordsFilter = class {
240
255
  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]);
256
+ const { customWords = [], includeInsults = false, stripSuffixes = false } = Array.isArray(options) ? { customWords: options } : options;
257
+ this.words = /* @__PURE__ */ new Map();
258
+ this.stripSuffixes = stripSuffixes;
259
+ this.safeStems = safe_stems_default.map((word) => ({ normalized: this.normalize(word), word }));
260
+ this.addEntries(words_default, "words");
261
+ if (includeInsults) this.addEntries(insults_default, "insults");
262
+ this.addEntries(customWords, "custom");
245
263
  }
246
264
  /**
247
265
  * Normalizes Arabic text so spelling variants compare equal:
248
- * removes diacritics (Tashkeel) and tatweel, unifies Alef/Teh Marbuta/Alef Maksura
249
- * forms, and collapses stretched letters (عاااارة -> عارة).
266
+ * removes diacritics (Tashkeel), tatweel and invisible characters, unifies
267
+ * Alef/Teh Marbuta/Alef Maksura forms and the Persian/Kurdish keyboard letters
268
+ * ی ک ە, turns Arabic-Indic digits into 0-9, and collapses stretched letters
269
+ * (عاااارة -> عارة).
250
270
  */
251
271
  normalizeArabic(text) {
252
- return text.replace(/[ً-ٰٟـ]/g, "").replace(/[أإآٱ]/g, "\u0627").replace(/ة/g, "\u0647").replace(/ى/g, "\u064A").replace(/(.)\1+/gu, "$1").toLowerCase();
272
+ return text.replace(/[\u064B-\u065F\u0670\u0640]/g, "").replace(INVISIBLE_CHARS, "").replace(/[أإآٱ]/g, "\u0627").replace(/[ةەہۀ]/g, "\u0647").replace(/[ىی]/g, "\u064A").replace(/[کڪ]/g, "\u0643").replace(/[٠-٩]/g, (digit) => String(digit.charCodeAt(0) - 1632)).replace(/[۰-۹]/g, (digit) => String(digit.charCodeAt(0) - 1776)).replace(/(.)\1+/gu, "$1").toLowerCase();
273
+ }
274
+ /**
275
+ * Folds Arabizi (Arabic written in Latin letters and digits) so spelling
276
+ * variants compare equal: ga7ba / qahba / gahba, kosomak / kusomak,
277
+ * teezak / tizak. Only words made purely of Latin letters and digits are
278
+ * folded; expects text already passed through normalizeArabic.
279
+ */
280
+ foldArabizi(word) {
281
+ if (!/^[a-z0-9]+$/.test(word) || !/[a-z]/.test(word)) return word;
282
+ 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");
283
+ }
284
+ /** Normalizes a word or token for comparison. */
285
+ normalize(text) {
286
+ const word = this.normalizeArabic(text);
287
+ return /[\u0600-\uFEFF]/.test(word) ? word.replace(/[0-9]/g, "").replace(/(.)\1+/gu, "$1") : this.foldArabizi(word);
288
+ }
289
+ /**
290
+ * Adds words from one list. A word already present keeps its first source.
291
+ */
292
+ addEntries(newWords, source) {
293
+ for (const word of newWords) {
294
+ const normalized = this.normalize(word);
295
+ if (!this.words.has(normalized)) this.words.set(normalized, { word, source });
296
+ }
253
297
  }
254
298
  /**
255
299
  * Checks a single word, also trying it with attached prefixes removed
256
- * (والكحبة -> كحبة, بطيزك -> طيزك).
300
+ * (والكحبة -> كحبة, بطيزك -> طيزك), and suffixes removed when stripSuffixes is on.
301
+ * Returns the matched entry, or null.
302
+ * A match cleared by a safe stem is returned with `suppressedBy` set.
257
303
  */
258
- isProfaneWord(token) {
259
- const word = this.normalizeArabic(token);
260
- if (this.words.has(word)) return true;
261
- const stems = [word];
304
+ matchWord(token) {
305
+ const word = this.normalize(token);
306
+ const direct = this.words.get(word);
307
+ if (direct) {
308
+ return { normalized: word, entry: direct.word, source: direct.source, prefix: "", suffix: "" };
309
+ }
310
+ const candidates = [{ prefix: "", stem: word, suffix: "" }];
262
311
  for (const conjunction of CONJUNCTIONS) {
263
312
  for (const preposition of PREPOSITIONS) {
264
313
  for (const article of ARTICLES) {
265
314
  const prefix = conjunction + preposition + article;
266
315
  if (prefix && word.startsWith(prefix) && word.length - prefix.length >= 2) {
267
- stems.push(word.slice(prefix.length));
316
+ candidates.push({ prefix, stem: word.slice(prefix.length), suffix: "" });
317
+ }
318
+ }
319
+ }
320
+ }
321
+ if (this.stripSuffixes) {
322
+ for (const { prefix, stem } of [...candidates]) {
323
+ for (const suffix of SUFFIXES) {
324
+ if (stem.endsWith(suffix) && stem.length - suffix.length >= 2) {
325
+ candidates.push({ prefix, stem: stem.slice(0, -suffix.length), suffix });
268
326
  }
269
327
  }
270
328
  }
271
329
  }
272
- if (stems.some((stem) => this.safeStems.some((safe) => stem.startsWith(safe)))) {
273
- return false;
330
+ const match = candidates.find(({ stem }) => this.words.has(stem));
331
+ if (!match) return null;
332
+ const { word: entry, source } = this.words.get(match.stem);
333
+ const result = {
334
+ normalized: word,
335
+ entry,
336
+ source,
337
+ prefix: match.prefix,
338
+ suffix: match.suffix
339
+ };
340
+ for (const { stem } of candidates) {
341
+ const safe = this.safeStems.find(({ normalized }) => stem.startsWith(normalized));
342
+ if (safe) return { ...result, suppressedBy: safe.word };
343
+ }
344
+ return result;
345
+ }
346
+ /**
347
+ * Finds every match in the text, including ones cleared by a safe stem.
348
+ * A token with invisible characters is matched whole first (عا‌رة -> عارة);
349
+ * if that finds nothing profane, each visible piece is tried on its own, so an
350
+ * invisible character between two words can't hide a bad word either.
351
+ */
352
+ *findMatches(text) {
353
+ const pattern = new RegExp(WORD_PATTERN.source, "g");
354
+ let found;
355
+ while (found = pattern.exec(text)) {
356
+ const token = found[0];
357
+ const start = found.index;
358
+ const whole = this.matchWord(token);
359
+ const wholeMatch = whole && { token, start, end: start + token.length, ...whole };
360
+ if (whole && whole.suppressedBy === void 0 || !HAS_INVISIBLE.test(token)) {
361
+ if (wholeMatch) yield wholeMatch;
362
+ continue;
363
+ }
364
+ const pieces = [];
365
+ const piecePattern = new RegExp(VISIBLE_RUN.source, "g");
366
+ let piece;
367
+ while (piece = piecePattern.exec(token)) {
368
+ const match = this.matchWord(piece[0]);
369
+ const pieceStart = start + piece.index;
370
+ if (match) pieces.push({ token: piece[0], start: pieceStart, end: pieceStart + piece[0].length, ...match });
371
+ }
372
+ if (pieces.length) yield* pieces;
373
+ else if (wholeMatch) yield wholeMatch;
274
374
  }
275
- return stems.some((stem) => this.words.has(stem));
276
375
  }
277
376
  /**
278
377
  * Checks if a given text contains profane words.
279
378
  */
280
379
  isProfane(text) {
281
380
  if (!text) return false;
282
- for (const token of text.match(WORD_PATTERN) ?? []) {
283
- if (this.isProfaneWord(token)) {
284
- return true;
285
- }
381
+ for (const match of this.findMatches(text)) {
382
+ if (match.suppressedBy === void 0) return true;
286
383
  }
287
384
  return false;
288
385
  }
386
+ /**
387
+ * Lists every bad word in the text with its position in the original input
388
+ * and why it matched, e.g. to highlight words or review a match before blocking.
389
+ */
390
+ inspect(text, options = {}) {
391
+ if (!text) return [];
392
+ const matches = [];
393
+ for (const match of this.findMatches(text)) {
394
+ if (match.suppressedBy === void 0 || options.includeSuppressed) matches.push(match);
395
+ }
396
+ return matches;
397
+ }
289
398
  /**
290
399
  * Replaces profane words with a placeholder string.
291
400
  */
292
401
  clean(text, placeholder = "*") {
293
402
  if (!text) return "";
294
- return text.replace(
295
- WORD_PATTERN,
296
- (token) => this.isProfaneWord(token) ? placeholder.repeat(token.length) : token
297
- );
403
+ let result = "";
404
+ let last = 0;
405
+ for (const { start, end } of this.inspect(text)) {
406
+ result += text.slice(last, start) + placeholder.repeat(end - start);
407
+ last = end;
408
+ }
409
+ return result + text.slice(last);
298
410
  }
299
411
  /**
300
412
  * Dynamically add words to the active dictionary.
301
413
  */
302
414
  addWords(newWords) {
303
- newWords.forEach((word) => this.words.add(this.normalizeArabic(word)));
415
+ this.addEntries(newWords, "custom");
304
416
  }
305
417
  /**
306
418
  * Remove words from the active filter dictionary.
307
419
  */
308
420
  removeWords(wordsToRemove) {
309
- wordsToRemove.forEach((word) => this.words.delete(this.normalizeArabic(word)));
421
+ wordsToRemove.forEach((word) => this.words.delete(this.normalize(word)));
310
422
  }
311
423
  };
312
424
  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,87 +205,193 @@ 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"
205
212
  ];
206
213
 
207
214
  // src/index.ts
208
- var WORD_PATTERN = /[A-Za-z0-9_À-ɏ؀-؋؍-ؚ؜-؞ؠ-٩ٮ-ۓە-ۿݐ-ݿࢠ-ࣿﭐ-﷿ﹰ-ﻼ]+/g;
215
+ var INVISIBLE = "\\u00AD\\u061C\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF";
216
+ var INVISIBLE_CHARS = new RegExp(`[${INVISIBLE}]`, "g");
217
+ var HAS_INVISIBLE = new RegExp(`[${INVISIBLE}]`);
218
+ var VISIBLE_RUN = new RegExp(`[^${INVISIBLE}]+`, "g");
219
+ var WORD_PATTERN = new RegExp(
220
+ `[A-Za-z0-9_\xC0-\u024F\u0600-\u060B\u060D-\u061A\u061C-\u061E\u0620-\u0669\u066E-\u06D3\u06D5-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFC${INVISIBLE}]+`,
221
+ "g"
222
+ );
209
223
  var CONJUNCTIONS = ["", "\u0648", "\u0641"];
210
224
  var PREPOSITIONS = ["", "\u0628", "\u0644", "\u0643"];
211
225
  var ARTICLES = ["", "\u0627\u0644"];
226
+ var SUFFIXES = ["\u064A", "\u0643", "\u062C", "\u0647", "\u0647\u0627", "\u0646\u0627", "\u0643\u0645", "\u0647\u0645"];
212
227
  var IraqiBadWordsFilter = class {
213
228
  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]);
229
+ const { customWords = [], includeInsults = false, stripSuffixes = false } = Array.isArray(options) ? { customWords: options } : options;
230
+ this.words = /* @__PURE__ */ new Map();
231
+ this.stripSuffixes = stripSuffixes;
232
+ this.safeStems = safe_stems_default.map((word) => ({ normalized: this.normalize(word), word }));
233
+ this.addEntries(words_default, "words");
234
+ if (includeInsults) this.addEntries(insults_default, "insults");
235
+ this.addEntries(customWords, "custom");
218
236
  }
219
237
  /**
220
238
  * Normalizes Arabic text so spelling variants compare equal:
221
- * removes diacritics (Tashkeel) and tatweel, unifies Alef/Teh Marbuta/Alef Maksura
222
- * forms, and collapses stretched letters (عاااارة -> عارة).
239
+ * removes diacritics (Tashkeel), tatweel and invisible characters, unifies
240
+ * Alef/Teh Marbuta/Alef Maksura forms and the Persian/Kurdish keyboard letters
241
+ * ی ک ە, turns Arabic-Indic digits into 0-9, and collapses stretched letters
242
+ * (عاااارة -> عارة).
223
243
  */
224
244
  normalizeArabic(text) {
225
- return text.replace(/[ً-ٰٟـ]/g, "").replace(/[أإآٱ]/g, "\u0627").replace(/ة/g, "\u0647").replace(/ى/g, "\u064A").replace(/(.)\1+/gu, "$1").toLowerCase();
245
+ return text.replace(/[\u064B-\u065F\u0670\u0640]/g, "").replace(INVISIBLE_CHARS, "").replace(/[أإآٱ]/g, "\u0627").replace(/[ةەہۀ]/g, "\u0647").replace(/[ىی]/g, "\u064A").replace(/[کڪ]/g, "\u0643").replace(/[٠-٩]/g, (digit) => String(digit.charCodeAt(0) - 1632)).replace(/[۰-۹]/g, (digit) => String(digit.charCodeAt(0) - 1776)).replace(/(.)\1+/gu, "$1").toLowerCase();
246
+ }
247
+ /**
248
+ * Folds Arabizi (Arabic written in Latin letters and digits) so spelling
249
+ * variants compare equal: ga7ba / qahba / gahba, kosomak / kusomak,
250
+ * teezak / tizak. Only words made purely of Latin letters and digits are
251
+ * folded; expects text already passed through normalizeArabic.
252
+ */
253
+ foldArabizi(word) {
254
+ if (!/^[a-z0-9]+$/.test(word) || !/[a-z]/.test(word)) return word;
255
+ 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");
256
+ }
257
+ /** Normalizes a word or token for comparison. */
258
+ normalize(text) {
259
+ const word = this.normalizeArabic(text);
260
+ return /[\u0600-\uFEFF]/.test(word) ? word.replace(/[0-9]/g, "").replace(/(.)\1+/gu, "$1") : this.foldArabizi(word);
261
+ }
262
+ /**
263
+ * Adds words from one list. A word already present keeps its first source.
264
+ */
265
+ addEntries(newWords, source) {
266
+ for (const word of newWords) {
267
+ const normalized = this.normalize(word);
268
+ if (!this.words.has(normalized)) this.words.set(normalized, { word, source });
269
+ }
226
270
  }
227
271
  /**
228
272
  * Checks a single word, also trying it with attached prefixes removed
229
- * (والكحبة -> كحبة, بطيزك -> طيزك).
273
+ * (والكحبة -> كحبة, بطيزك -> طيزك), and suffixes removed when stripSuffixes is on.
274
+ * Returns the matched entry, or null.
275
+ * A match cleared by a safe stem is returned with `suppressedBy` set.
230
276
  */
231
- isProfaneWord(token) {
232
- const word = this.normalizeArabic(token);
233
- if (this.words.has(word)) return true;
234
- const stems = [word];
277
+ matchWord(token) {
278
+ const word = this.normalize(token);
279
+ const direct = this.words.get(word);
280
+ if (direct) {
281
+ return { normalized: word, entry: direct.word, source: direct.source, prefix: "", suffix: "" };
282
+ }
283
+ const candidates = [{ prefix: "", stem: word, suffix: "" }];
235
284
  for (const conjunction of CONJUNCTIONS) {
236
285
  for (const preposition of PREPOSITIONS) {
237
286
  for (const article of ARTICLES) {
238
287
  const prefix = conjunction + preposition + article;
239
288
  if (prefix && word.startsWith(prefix) && word.length - prefix.length >= 2) {
240
- stems.push(word.slice(prefix.length));
289
+ candidates.push({ prefix, stem: word.slice(prefix.length), suffix: "" });
290
+ }
291
+ }
292
+ }
293
+ }
294
+ if (this.stripSuffixes) {
295
+ for (const { prefix, stem } of [...candidates]) {
296
+ for (const suffix of SUFFIXES) {
297
+ if (stem.endsWith(suffix) && stem.length - suffix.length >= 2) {
298
+ candidates.push({ prefix, stem: stem.slice(0, -suffix.length), suffix });
241
299
  }
242
300
  }
243
301
  }
244
302
  }
245
- if (stems.some((stem) => this.safeStems.some((safe) => stem.startsWith(safe)))) {
246
- return false;
303
+ const match = candidates.find(({ stem }) => this.words.has(stem));
304
+ if (!match) return null;
305
+ const { word: entry, source } = this.words.get(match.stem);
306
+ const result = {
307
+ normalized: word,
308
+ entry,
309
+ source,
310
+ prefix: match.prefix,
311
+ suffix: match.suffix
312
+ };
313
+ for (const { stem } of candidates) {
314
+ const safe = this.safeStems.find(({ normalized }) => stem.startsWith(normalized));
315
+ if (safe) return { ...result, suppressedBy: safe.word };
316
+ }
317
+ return result;
318
+ }
319
+ /**
320
+ * Finds every match in the text, including ones cleared by a safe stem.
321
+ * A token with invisible characters is matched whole first (عا‌رة -> عارة);
322
+ * if that finds nothing profane, each visible piece is tried on its own, so an
323
+ * invisible character between two words can't hide a bad word either.
324
+ */
325
+ *findMatches(text) {
326
+ const pattern = new RegExp(WORD_PATTERN.source, "g");
327
+ let found;
328
+ while (found = pattern.exec(text)) {
329
+ const token = found[0];
330
+ const start = found.index;
331
+ const whole = this.matchWord(token);
332
+ const wholeMatch = whole && { token, start, end: start + token.length, ...whole };
333
+ if (whole && whole.suppressedBy === void 0 || !HAS_INVISIBLE.test(token)) {
334
+ if (wholeMatch) yield wholeMatch;
335
+ continue;
336
+ }
337
+ const pieces = [];
338
+ const piecePattern = new RegExp(VISIBLE_RUN.source, "g");
339
+ let piece;
340
+ while (piece = piecePattern.exec(token)) {
341
+ const match = this.matchWord(piece[0]);
342
+ const pieceStart = start + piece.index;
343
+ if (match) pieces.push({ token: piece[0], start: pieceStart, end: pieceStart + piece[0].length, ...match });
344
+ }
345
+ if (pieces.length) yield* pieces;
346
+ else if (wholeMatch) yield wholeMatch;
247
347
  }
248
- return stems.some((stem) => this.words.has(stem));
249
348
  }
250
349
  /**
251
350
  * Checks if a given text contains profane words.
252
351
  */
253
352
  isProfane(text) {
254
353
  if (!text) return false;
255
- for (const token of text.match(WORD_PATTERN) ?? []) {
256
- if (this.isProfaneWord(token)) {
257
- return true;
258
- }
354
+ for (const match of this.findMatches(text)) {
355
+ if (match.suppressedBy === void 0) return true;
259
356
  }
260
357
  return false;
261
358
  }
359
+ /**
360
+ * Lists every bad word in the text with its position in the original input
361
+ * and why it matched, e.g. to highlight words or review a match before blocking.
362
+ */
363
+ inspect(text, options = {}) {
364
+ if (!text) return [];
365
+ const matches = [];
366
+ for (const match of this.findMatches(text)) {
367
+ if (match.suppressedBy === void 0 || options.includeSuppressed) matches.push(match);
368
+ }
369
+ return matches;
370
+ }
262
371
  /**
263
372
  * Replaces profane words with a placeholder string.
264
373
  */
265
374
  clean(text, placeholder = "*") {
266
375
  if (!text) return "";
267
- return text.replace(
268
- WORD_PATTERN,
269
- (token) => this.isProfaneWord(token) ? placeholder.repeat(token.length) : token
270
- );
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);
271
383
  }
272
384
  /**
273
385
  * Dynamically add words to the active dictionary.
274
386
  */
275
387
  addWords(newWords) {
276
- newWords.forEach((word) => this.words.add(this.normalizeArabic(word)));
388
+ this.addEntries(newWords, "custom");
277
389
  }
278
390
  /**
279
391
  * Remove words from the active filter dictionary.
280
392
  */
281
393
  removeWords(wordsToRemove) {
282
- wordsToRemove.forEach((word) => this.words.delete(this.normalizeArabic(word)));
394
+ wordsToRemove.forEach((word) => this.words.delete(this.normalize(word)));
283
395
  }
284
396
  };
285
397
  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.1",
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",