@sideid/id-profanity-filter 1.12.0 → 1.13.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/package.json CHANGED
@@ -1,14 +1,21 @@
1
1
  {
2
2
  "name": "@sideid/id-profanity-filter",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "description": "Library filter kata kotor dalam Bahasa Indonesia",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.esm.js",
7
7
  "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.esm.js",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
8
15
  "scripts": {
9
16
  "build": "rollup -c",
10
17
  "test": "jest",
11
- "lint": "eslint --ext .ts src/",
18
+ "lint": "eslint src/",
12
19
  "format": "prettier --write \"src/**/*.ts\""
13
20
  },
14
21
  "keywords": [
package/rollup.config.mjs CHANGED
@@ -3,7 +3,11 @@ import commonjs from '@rollup/plugin-commonjs';
3
3
  import typescript from '@rollup/plugin-typescript';
4
4
  import json from '@rollup/plugin-json';
5
5
  import dts from 'rollup-plugin-dts';
6
- import pkg from './package.json' assert { type: 'json' };
6
+ import { readFileSync } from 'node:fs';
7
+
8
+ const pkg = JSON.parse(
9
+ readFileSync(new URL('./package.json', import.meta.url), 'utf-8')
10
+ );
7
11
 
8
12
  export default [
9
13
  {
@@ -79,7 +79,7 @@ export const sexual: ProfanityWord[] = [
79
79
  category: 'sexual',
80
80
  region: 'general',
81
81
  severity: 0.9,
82
- aliases: ['ngew', 'we'],
82
+ aliases: ['ngew'],
83
83
  description: 'Istilah kasar untuk aktivitas seksual',
84
84
  context: 'Kata vulgar yang merujuk pada aktivitas seksual',
85
85
  },
@@ -6,7 +6,7 @@ export const general: ProfanityWord[] = [
6
6
  category: 'profanity',
7
7
  region: 'general',
8
8
  severity: 0.7,
9
- aliases: ['anjay', 'anjir', 'anying', 'njing', 'anj', 'anjg', 'ajg'],
9
+ aliases: ['anjay', 'anjir', 'anying', 'njing', 'anj', 'anjg', 'ajg', 'anjic', 'anjink'],
10
10
  description: 'Mengacu pada hewan anjing, digunakan sebagai umpatan',
11
11
  context: 'Umpatan umum untuk menunjukkan kemarahan atau ketidaksetujuan',
12
12
  },
@@ -115,7 +115,7 @@ export const general: ProfanityWord[] = [
115
115
  category: 'insult',
116
116
  region: 'general',
117
117
  severity: 0.5,
118
- aliases: ['sialn', 'sl'],
118
+ aliases: ['sialn'],
119
119
  description: 'Kata yang mengacu pada orang yang membawa sial',
120
120
  context: 'Hinaan untuk menyebut orang yang dianggap membawa sial',
121
121
  },
@@ -178,7 +178,7 @@ export const general: ProfanityWord[] = [
178
178
  category: 'sexual',
179
179
  region: 'general',
180
180
  severity: 0.9,
181
- aliases: ['ngew', 'we'],
181
+ aliases: ['ngew'],
182
182
  description: 'Istilah kasar untuk aktivitas seksual',
183
183
  context: 'Kata vulgar yang merujuk pada aktivitas seksual',
184
184
  },
@@ -15,16 +15,7 @@ import { batak, batakWords } from './regions/batak';
15
15
  import { minang, minangWords } from './regions/minang';
16
16
  import { bali, baliWords } from './regions/bali';
17
17
  import { madura, maduraWords } from './regions/madura';
18
- // import { bugis, bugisWords } from './regions/bugis';
19
18
  import { aceh, acehWords } from './regions/aceh';
20
- // import { ambon, ambonWords } from './regions/ambon';
21
- // import { papua, papuaWords } from './regions/papua';
22
- // import { manado, manadoWords } from './regions/manado';
23
- // import { banjar, banjarWords } from './regions/banjar';
24
- // import { palembang, palembangWords } from './regions/palembang';
25
- // import { lampung, lampungWords } from './regions/lampung';
26
- // import { ntt, nttWords } from './regions/ntt';
27
- // import { ntb, ntbWords } from './regions/ntb';
28
19
 
29
20
  export const wordCategories = {
30
21
  sexual: sexualWords,
@@ -45,16 +36,7 @@ export const wordRegions = {
45
36
  minang: minangWords,
46
37
  bali: baliWords,
47
38
  madura: maduraWords,
48
- // bugis: bugisWords,
49
39
  aceh: acehWords,
50
- // ambon: ambonWords,
51
- // papua: papuaWords,
52
- // manado: manadoWords,
53
- // banjar: banjarWords,
54
- // palembang: palembangWords,
55
- // lampung: lampungWords,
56
- // ntt: nttWords,
57
- // ntb: ntbWords,
58
40
  };
59
41
 
60
42
  export const wordObjects: ProfanityWord[] = [
@@ -66,16 +48,7 @@ export const wordObjects: ProfanityWord[] = [
66
48
  ...minang,
67
49
  ...bali,
68
50
  ...madura,
69
- // ...bugis,
70
51
  ...aceh,
71
- // ...ambon,
72
- // ...papua,
73
- // ...manado,
74
- // ...banjar,
75
- // ...palembang,
76
- // ...lampung,
77
- // ...ntt,
78
- // ...ntb,
79
52
  ];
80
53
 
81
54
  export const allWords: string[] = wordObjects.map((item) => item.word);
@@ -17,6 +17,7 @@ interface FindProfanityFunction {
17
17
  * @returns FilterResult dengan hasil filter
18
18
  */
19
19
  export function filter(text: string, options: FilterOptions = {}): FilterResult {
20
+ const mergedOptions = { ...DEFAULT_OPTIONS, ...options };
20
21
  const {
21
22
  replaceWith = '*',
22
23
  fullWordCensor = true,
@@ -25,31 +26,10 @@ export function filter(text: string, options: FilterOptions = {}): FilterResult
25
26
  checkSubstring = false,
26
27
  useRandomGrawlix = false,
27
28
  keepFirstAndLast = false,
28
- indonesianVariation = false,
29
29
  detectSplit = false,
30
- detectSimilarity = false,
31
- useLevenshtein = false,
32
- maxLevenshteinDistance = 2,
33
- similarityThreshold = 0.8,
34
- } = { ...DEFAULT_OPTIONS, ...options };
35
-
36
- const matches = findProfanity(text, {
37
- ...options,
38
- detectLeetSpeak,
39
- whitelist,
40
- checkSubstring,
41
- indonesianVariation,
42
- detectSplit,
43
- detectSimilarity,
44
- useLevenshtein,
45
- maxLevenshteinDistance,
46
- similarityThreshold,
47
- });
30
+ } = mergedOptions;
48
31
 
49
- const actualMatches: Map<string, string[]> =
50
- (findProfanity as FindProfanityFunction).lastActualMatches || new Map();
51
-
52
- const matchDetails = findProfanityWithMetadata(text, options);
32
+ const matches = findProfanity(text, mergedOptions);
53
33
 
54
34
  if (matches.length === 0) {
55
35
  return {
@@ -59,15 +39,42 @@ export function filter(text: string, options: FilterOptions = {}): FilterResult
59
39
  };
60
40
  }
61
41
 
62
- let filteredText = text;
42
+ const actualMatches: Map<string, string[]> =
43
+ (findProfanity as FindProfanityFunction).lastActualMatches || new Map();
44
+ const matchDetails = findProfanityWithMetadata(text, mergedOptions);
45
+ const normalizedWhitelist = whitelist.map((w) => w.toLowerCase());
63
46
 
47
+ let filteredText = text;
64
48
  const replacements: Array<{
65
49
  original: string;
66
50
  censored: string;
67
51
  metadata?: ProfanityWord;
68
52
  }> = [];
69
53
 
70
- matches.forEach((word) => {
54
+ const getCensoredWord = (originalWord: string): string => {
55
+ if (useRandomGrawlix) {
56
+ return makeRandomGrawlixString(originalWord.length);
57
+ }
58
+ return censorWord(originalWord, replaceWith, !fullWordCensor && keepFirstAndLast);
59
+ };
60
+
61
+ const applyReplacement = (pattern: RegExp, metadata?: ProfanityWord) => {
62
+ filteredText = filteredText.replace(pattern, (matchedStr) => {
63
+ if (normalizedWhitelist.includes(matchedStr.toLowerCase())) {
64
+ return matchedStr;
65
+ }
66
+
67
+ const censored = getCensoredWord(matchedStr);
68
+ replacements.push({
69
+ original: matchedStr,
70
+ censored,
71
+ metadata,
72
+ });
73
+ return censored;
74
+ });
75
+ };
76
+
77
+ for (const word of matches) {
71
78
  const metadata = matchDetails.find(
72
79
  (m) =>
73
80
  m.word.toLowerCase() === word.toLowerCase() ||
@@ -75,163 +82,36 @@ export function filter(text: string, options: FilterOptions = {}): FilterResult
75
82
  );
76
83
 
77
84
  const variants = actualMatches.get(word.toLowerCase()) || [];
78
- variants.push(word);
85
+ const allVariants = [...new Set([...variants, word])].sort((a, b) => b.length - a.length);
79
86
 
80
- const uniqueVariants = [...new Set(variants)];
81
-
82
- uniqueVariants.forEach((variant) => {
83
- const regex = new RegExp(`\\b${escapeRegExp(variant)}\\b`, 'gi');
84
-
85
- let match;
86
- while ((match = regex.exec(filteredText)) !== null) {
87
- const originalWord = match[0];
88
-
89
- if (whitelist.includes(originalWord.toLowerCase())) continue;
90
-
91
- let censoredWord;
92
- if (useRandomGrawlix) {
93
- censoredWord = makeRandomGrawlixString(originalWord.length);
94
- } else {
95
- censoredWord = censorWord(originalWord, replaceWith, !fullWordCensor && keepFirstAndLast);
96
- }
97
-
98
- replacements.push({
99
- original: originalWord,
100
- censored: censoredWord,
101
- metadata,
102
- });
103
-
104
- filteredText = filteredText.replace(
105
- new RegExp(`\\b${escapeRegExp(originalWord)}\\b`, 'g'),
106
- censoredWord
107
- );
108
- }
109
- });
110
-
111
- if (detectSplit || detectLeetSpeak) {
112
- if (detectLeetSpeak) {
113
- const leetRegex = createWordRegex(word, {
114
- wholeWord: true,
115
- caseSensitive: false,
116
- leetSpeak: true,
117
- detectSplit: false,
118
- indonesianVariation: false,
119
- });
120
-
121
- let match;
122
- while ((match = leetRegex.exec(filteredText)) !== null) {
123
- const originalWord = match[0];
124
-
125
- if (whitelist.includes(originalWord.toLowerCase())) continue;
126
-
127
- let censoredWord;
128
- if (useRandomGrawlix) {
129
- censoredWord = makeRandomGrawlixString(originalWord.length);
130
- } else {
131
- censoredWord = censorWord(
132
- originalWord,
133
- replaceWith,
134
- !fullWordCensor && keepFirstAndLast
135
- );
136
- }
137
-
138
- replacements.push({
139
- original: originalWord,
140
- censored: censoredWord,
141
- metadata,
142
- });
143
-
144
- filteredText = filteredText.replace(
145
- new RegExp(escapeRegExp(originalWord), 'g'),
146
- censoredWord
147
- );
148
- }
149
- }
87
+ for (const variant of allVariants) {
88
+ const boundaryPattern = checkSubstring
89
+ ? escapeRegExp(variant)
90
+ : `\\b${escapeRegExp(variant)}\\b`;
91
+ applyReplacement(new RegExp(boundaryPattern, 'gi'), metadata);
92
+ }
150
93
 
151
- if (detectSplit) {
152
- const splitRegex = createWordRegex(word, {
153
- wholeWord: false,
154
- caseSensitive: false,
155
- leetSpeak: false,
156
- detectSplit: true,
157
- indonesianVariation: false,
158
- });
159
-
160
- let match;
161
- while ((match = splitRegex.exec(filteredText)) !== null) {
162
- const originalWord = match[0];
163
-
164
- if (whitelist.includes(originalWord.toLowerCase())) continue;
165
-
166
- let censoredWord;
167
- if (useRandomGrawlix) {
168
- censoredWord = makeRandomGrawlixString(originalWord.length);
169
- } else {
170
- censoredWord = censorWord(
171
- originalWord,
172
- replaceWith,
173
- !fullWordCensor && keepFirstAndLast
174
- );
175
- }
176
-
177
- replacements.push({
178
- original: originalWord,
179
- censored: censoredWord,
180
- metadata,
181
- });
182
-
183
- filteredText = filteredText.replace(
184
- new RegExp(escapeRegExp(originalWord), 'g'),
185
- censoredWord
186
- );
187
- }
188
- }
94
+ if (detectLeetSpeak) {
95
+ const leetRegex = createWordRegex(word, {
96
+ wholeWord: !checkSubstring,
97
+ caseSensitive: false,
98
+ leetSpeak: true,
99
+ detectSplit: false,
100
+ indonesianVariation: false,
101
+ });
102
+ applyReplacement(leetRegex, metadata);
189
103
  }
190
- });
191
-
192
- if (detectSimilarity && useLevenshtein) {
193
- matches.forEach((word) => {
194
- const metadata = matchDetails.find(
195
- (m) =>
196
- m.word.toLowerCase() === word.toLowerCase() ||
197
- (m.aliases && m.aliases.some((alias) => alias.toLowerCase() === word.toLowerCase()))
198
- );
199
-
200
- const variants = actualMatches.get(word.toLowerCase()) || [];
201
-
202
- variants.forEach((variant) => {
203
- const exactVariantRegex = new RegExp(`\\b${escapeRegExp(variant)}\\b`, 'gi');
204
-
205
- let match;
206
- while ((match = exactVariantRegex.exec(filteredText)) !== null) {
207
- const originalWord = match[0];
208
-
209
- if (whitelist.includes(originalWord.toLowerCase())) continue;
210
-
211
- let censoredWord;
212
- if (useRandomGrawlix) {
213
- censoredWord = makeRandomGrawlixString(originalWord.length);
214
- } else {
215
- censoredWord = censorWord(
216
- originalWord,
217
- replaceWith,
218
- !fullWordCensor && keepFirstAndLast
219
- );
220
- }
221
-
222
- replacements.push({
223
- original: originalWord,
224
- censored: censoredWord,
225
- metadata,
226
- });
227
-
228
- filteredText = filteredText.replace(
229
- new RegExp(`\\b${escapeRegExp(originalWord)}\\b`, 'g'),
230
- censoredWord
231
- );
232
- }
104
+
105
+ if (detectSplit) {
106
+ const splitRegex = createWordRegex(word, {
107
+ wholeWord: false,
108
+ caseSensitive: false,
109
+ leetSpeak: false,
110
+ detectSplit: true,
111
+ indonesianVariation: false,
233
112
  });
234
- });
113
+ applyReplacement(splitRegex, metadata);
114
+ }
235
115
  }
236
116
 
237
117
  return {
@@ -10,18 +10,23 @@ import {
10
10
  import { DEFAULT_OPTIONS } from '../config/options';
11
11
  import { AhoCorasick } from '../utils/ahoCorasick';
12
12
 
13
- const globalAhoCorasick = new AhoCorasick();
14
- let ahoCorasickInitialized = false;
15
-
16
- function initializeAhoCorasick(words: string[]) {
17
- if (ahoCorasickInitialized) return;
18
-
19
- for (const word of words) {
20
- globalAhoCorasick.addPattern(word);
13
+ let defaultAhoCorasick: AhoCorasick | null = null;
14
+
15
+ function getDefaultAhoCorasick(): AhoCorasick {
16
+ if (!defaultAhoCorasick) {
17
+ const ac = new AhoCorasick();
18
+ for (const wordObj of wordObjects) {
19
+ ac.addPattern(wordObj.word);
20
+ if (wordObj.aliases) {
21
+ for (const alias of wordObj.aliases) {
22
+ ac.addPattern(alias);
23
+ }
24
+ }
25
+ }
26
+ ac.build();
27
+ defaultAhoCorasick = ac;
21
28
  }
22
-
23
- globalAhoCorasick.build();
24
- ahoCorasickInitialized = true;
29
+ return defaultAhoCorasick;
25
30
  }
26
31
 
27
32
  interface FindProfanityFunction {
@@ -54,13 +59,16 @@ export function findProfanity(text: string, options: FilterOptions = {}): string
54
59
  maxLevenshteinDistance = 2,
55
60
  } = { ...DEFAULT_OPTIONS, ...options };
56
61
 
62
+ const hasCustomWordList = Boolean(wordList && wordList.length > 0);
57
63
  const normalizedWhitelist = whitelist.map((w) => w.toLowerCase());
58
-
59
64
  const normalizedText = normalizeText(text);
60
65
 
61
- let baseWordsToCheck: string[] = wordList.length > 0 ? wordList : [];
66
+ let baseWordsToCheck: string[] = [];
67
+ const aliasMap = new Map<string, string>();
62
68
 
63
- if (baseWordsToCheck.length === 0) {
69
+ if (hasCustomWordList) {
70
+ baseWordsToCheck = wordList;
71
+ } else {
64
72
  const filteredWords = wordObjects.filter((word) => {
65
73
  const matchCategory = categories ? categories.includes(word.category) : true;
66
74
  const matchRegion = regions ? regions.includes(word.region) : true;
@@ -69,26 +77,15 @@ export function findProfanity(text: string, options: FilterOptions = {}): string
69
77
  });
70
78
 
71
79
  baseWordsToCheck = filteredWords.map((word) => word.word);
72
- }
73
80
 
74
- const aliasMap = new Map<string, string>();
75
- wordObjects.forEach((wordObj) => {
76
- const matchCategory = categories ? categories.includes(wordObj.category) : true;
77
- const matchRegion = regions ? regions.includes(wordObj.region) : true;
78
- const matchSeverity = wordObj.severity >= severityThreshold;
79
-
80
- if (
81
- matchCategory &&
82
- matchRegion &&
83
- matchSeverity &&
84
- wordObj.aliases &&
85
- wordObj.aliases.length > 0
86
- ) {
87
- wordObj.aliases.forEach((alias) => {
88
- aliasMap.set(alias.toLowerCase(), wordObj.word.toLowerCase());
89
- });
90
- }
91
- });
81
+ filteredWords.forEach((wordObj) => {
82
+ if (wordObj.aliases && wordObj.aliases.length > 0) {
83
+ wordObj.aliases.forEach((alias) => {
84
+ aliasMap.set(alias.toLowerCase(), wordObj.word.toLowerCase());
85
+ });
86
+ }
87
+ });
88
+ }
92
89
 
93
90
  const wordsToCheck = [...baseWordsToCheck, ...Array.from(aliasMap.keys())].filter(
94
91
  (word) => !normalizedWhitelist.includes(word.toLowerCase())
@@ -103,6 +100,7 @@ export function findProfanity(text: string, options: FilterOptions = {}): string
103
100
 
104
101
  const passesFilters = (word: string): boolean => {
105
102
  if (normalizedWhitelist.includes(word.toLowerCase())) return false;
103
+ if (hasCustomWordList) return true;
106
104
 
107
105
  const metadata = getWordMetadata(word);
108
106
  if (!metadata) return false;
@@ -114,10 +112,29 @@ export function findProfanity(text: string, options: FilterOptions = {}): string
114
112
  return matchCategory && matchRegion && matchSeverity;
115
113
  };
116
114
 
117
- initializeAhoCorasick(wordsToCheck);
115
+ let ac: AhoCorasick;
116
+ if (hasCustomWordList) {
117
+ ac = new AhoCorasick();
118
+ for (const w of wordsToCheck) {
119
+ ac.addPattern(w);
120
+ }
121
+ ac.build();
122
+ } else {
123
+ ac = getDefaultAhoCorasick();
124
+ }
125
+
126
+ const occurrences = ac.searchWithPositions(normalizedText);
127
+ for (const occ of occurrences) {
128
+ const { pattern: match, start, end } = occ;
129
+
130
+ if (!checkSubstring) {
131
+ const isWordStart = start === 0 || !/[a-z0-9_]/i.test(normalizedText[start - 1]);
132
+ const isWordEnd = end === normalizedText.length || !/[a-z0-9_]/i.test(normalizedText[end]);
133
+ if (!isWordStart || !isWordEnd) {
134
+ continue;
135
+ }
136
+ }
118
137
 
119
- const basicMatches = globalAhoCorasick.searchUnique(normalizedText);
120
- for (const match of basicMatches) {
121
138
  if (normalizedWhitelist.includes(match.toLowerCase())) continue;
122
139
 
123
140
  const originalWord = aliasMap.get(match.toLowerCase()) || match.toLowerCase();
@@ -274,8 +291,8 @@ export function findProfanity(text: string, options: FilterOptions = {}): string
274
291
  /**
275
292
  * Mencari kata kotor lengkap dengan metadata
276
293
  *
277
- * @param text Teks yang akan diperika
278
- * @param options Opsi utnuk pencarian kata kotor
294
+ * @param text Teks yang akan diperiksa
295
+ * @param options Opsi untuk pencarian kata kotor
279
296
  * @return Array dari objek kata kotor yang ditemukan
280
297
  */
281
298
  export function findProfanityWithMetadata(
@@ -295,15 +312,24 @@ export function findProfanityWithMetadata(
295
312
  (obj.aliases && obj.aliases.some((alias) => alias.toLowerCase() === word.toLowerCase()))
296
313
  );
297
314
 
298
- return wordObject;
315
+ if (wordObject) {
316
+ return wordObject;
317
+ }
318
+
319
+ return {
320
+ word,
321
+ category: 'profanity' as ProfanityCategory,
322
+ region: 'general' as Region,
323
+ severity: 0.5,
324
+ };
299
325
  })
300
326
  .filter((word): word is ProfanityWord => word !== undefined);
301
327
  }
302
328
 
303
329
  /**
304
- * Mencari kategory kata kotor yang ada dalam teks
330
+ * Mencari kategori kata kotor yang ada dalam teks
305
331
  *
306
- * @param matchDetails Hasil pencarian dari fingProfanityWithMetadata()
332
+ * @param matchDetails Hasil pencarian dari findProfanityWithMetadata()
307
333
  * @return Array kategori unik
308
334
  */
309
335
  export function findCategories(matchDetails: ProfanityWord[]): ProfanityCategory[] {
@@ -319,7 +345,7 @@ export function findCategories(matchDetails: ProfanityWord[]): ProfanityCategory
319
345
  /**
320
346
  * Mencari region kata kotor yang ada dalam teks
321
347
  *
322
- * @param matchDetails Hasil pencarian dari fingProfanityWithMetadata()
348
+ * @param matchDetails Hasil pencarian dari findProfanityWithMetadata()
323
349
  * @return Array region unik
324
350
  */
325
351
  export function findRegions(matchDetails: ProfanityWord[]): Region[] {
@@ -343,7 +369,7 @@ export function calculateSeverity(matchDetails: ProfanityWord[]): number {
343
369
  return 0;
344
370
  }
345
371
 
346
- const countFactor = Math.min(matchDetails.length / 10, 1); // Maksimal 10 kata
372
+ const countFactor = Math.min(matchDetails.length / 10, 1);
347
373
 
348
374
  const categoryWeights: Record<ProfanityCategory, number> = {
349
375
  sexual: 0.9,
package/src/index.ts CHANGED
@@ -95,6 +95,14 @@ export class IDProfanityFilter {
95
95
  };
96
96
  }
97
97
 
98
+ /**
99
+ * Mengatur ulang opsi filter ke default
100
+ * @param options Opsi baru untuk override default
101
+ */
102
+ resetOptions(options: FilterOptions = {}) {
103
+ this.options = { ...DEFAULT_OPTIONS, ...options };
104
+ }
105
+
98
106
  /**
99
107
  * Menggunakan preset yang telah ditentukan
100
108
  * @param presetName Nama preset yang akan digunakan
@@ -16,17 +16,7 @@ export type Region =
16
16
  | 'minang'
17
17
  | 'bali'
18
18
  | 'madura'
19
- | 'bugis'
20
- | 'aceh'
21
- | 'ambon'
22
- | 'papua'
23
- | 'manado'
24
- | 'banjar'
25
- | 'palembang'
26
- | 'lampung'
27
- | 'ntt'
28
- | 'mandailing'
29
- | 'ntb';
19
+ | 'aceh';
30
20
 
31
21
  export interface ProfanityWord {
32
22
  word: string;
@@ -145,6 +145,43 @@ export class AhoCorasick {
145
145
  return new Set(matches.keys());
146
146
  }
147
147
 
148
+ /**
149
+ * Mencari semua kemunculan pola beserta posisi indeks awal dan akhirnya
150
+ * @param text Teks yang akan dicari
151
+ * @returns Array objek berisi pattern, start, dan end
152
+ */
153
+ searchWithPositions(text: string): Array<{ pattern: string; start: number; end: number }> {
154
+ if (!this.built) {
155
+ this.build();
156
+ }
157
+
158
+ const results: Array<{ pattern: string; start: number; end: number }> = [];
159
+ const normalizedText = text.toLowerCase();
160
+ let node = this.root;
161
+
162
+ for (let i = 0; i < normalizedText.length; i++) {
163
+ const char = normalizedText[i];
164
+
165
+ while (node !== this.root && !node.children.has(char)) {
166
+ node = node.fail!;
167
+ }
168
+
169
+ if (node.children.has(char)) {
170
+ node = node.children.get(char)!;
171
+ }
172
+
173
+ for (const match of node.output) {
174
+ results.push({
175
+ pattern: match,
176
+ start: i + 1 - match.length,
177
+ end: i + 1,
178
+ });
179
+ }
180
+ }
181
+
182
+ return results;
183
+ }
184
+
148
185
  /**
149
186
  * Mengecek apakah teks mengandung setidaknya satu pola
150
187
  * @param text Teks yang akan dicari
@@ -164,8 +164,7 @@ export function addIndonesianVariations(pattern: string): string {
164
164
  const variations = variationMap[lowerChar];
165
165
 
166
166
  if (variations && variations.length > 1) {
167
- // Create a character class with all variations
168
- result += `[${variations.join('')}]`;
167
+ result += `(?:${variations.join('|')})`;
169
168
  } else {
170
169
  result += char;
171
170
  }