mirror of
https://github.com/modrinth/code.git
synced 2026-08-30 19:46:33 +00:00
feat: implement profanity and non-standard text validators
This commit is contained in:
@@ -2,10 +2,12 @@
|
|||||||
"name": "@modrinth/moderation",
|
"name": "@modrinth/moderation",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "eslint . && prettier --check .",
|
"lint": "eslint . && prettier --check .",
|
||||||
"fix": "eslint . --fix && prettier --write .",
|
"fix": "eslint . --fix && prettier --write .",
|
||||||
|
"test": "node --test src/validators/*/tests.ts",
|
||||||
"intl:extract": "formatjs extract \"**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore \"node_modules/**/*\" --out-file src/locales/en-US/index.json --preserve-whitespace",
|
"intl:extract": "formatjs extract \"**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore \"node_modules/**/*\" --out-file src/locales/en-US/index.json --preserve-whitespace",
|
||||||
"intl:prune-local": "pnpm -w scripts i18n-icu-contract prune-local --scope packages/moderation"
|
"intl:prune-local": "pnpm -w scripts i18n-icu-contract prune-local --scope packages/moderation"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,3 +19,5 @@ export * from './types/quick-reply'
|
|||||||
export * from './types/reports'
|
export * from './types/reports'
|
||||||
export * from './types/settings'
|
export * from './types/settings'
|
||||||
export * from './utils'
|
export * from './utils'
|
||||||
|
export * from './validators/non-standard-text'
|
||||||
|
export * from './validators/profanity'
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
// the following are non-stardard text that are detected
|
||||||
|
export type NonStandardTextIssueKind =
|
||||||
|
| 'fancy' // styled characters such as `𝐀`, `Ⓐ`, or `A`.
|
||||||
|
| 'zalgo' // detached or excessive combining marks such as `a̴̵̶`.
|
||||||
|
| 'invisible' // hidden formatting such as a zero-width space or bidi override.
|
||||||
|
| 'control' // disallowed control characters such as a null byte.
|
||||||
|
| 'private-use' // characters from Unicode private-use areas.
|
||||||
|
| 'unassigned' // code points with no assigned Unicode character.
|
||||||
|
| 'surrogate' // malformed standalone UTF-16 surrogate code units.
|
||||||
|
|
||||||
|
export interface NonStandardTextIssue {
|
||||||
|
kind: NonStandardTextIssueKind
|
||||||
|
character: string
|
||||||
|
codePoint: string
|
||||||
|
index: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NonStandardTextResult {
|
||||||
|
valid: boolean
|
||||||
|
issues: NonStandardTextIssue[]
|
||||||
|
counts: Record<NonStandardTextIssueKind, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NonStandardTextOptions {
|
||||||
|
allowNewlines?: boolean
|
||||||
|
allowTabs?: boolean
|
||||||
|
maxCombiningMarksPerCharacter?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_MAX_COMBINING_MARKS_PER_CHARACTER = 2
|
||||||
|
|
||||||
|
const FANCY_RANGES: ReadonlyArray<readonly [number, number]> = [
|
||||||
|
[0x02b0, 0x02ff],
|
||||||
|
[0x1d400, 0x1d7ff],
|
||||||
|
[0x2460, 0x24ff],
|
||||||
|
[0x2070, 0x209f],
|
||||||
|
[0x2100, 0x214f],
|
||||||
|
[0xfb00, 0xfb06],
|
||||||
|
[0xff01, 0xff60],
|
||||||
|
[0x1f100, 0x1f1ad],
|
||||||
|
]
|
||||||
|
|
||||||
|
const MARK_PATTERN = /\p{M}/u
|
||||||
|
const CONTROL_PATTERN = /\p{Cc}/u
|
||||||
|
const FORMAT_PATTERN = /\p{Cf}/u
|
||||||
|
const PRIVATE_USE_PATTERN = /\p{Co}/u
|
||||||
|
const UNASSIGNED_PATTERN = /\p{Cn}/u
|
||||||
|
const LETTER_PATTERN = /\p{L}/u
|
||||||
|
const EXTENDED_PICTOGRAPHIC_PATTERN = /\p{Extended_Pictographic}/u
|
||||||
|
const UNIFIED_IDEOGRAPH_PATTERN = /\p{Unified_Ideograph}/u
|
||||||
|
|
||||||
|
function createCounts(): Record<NonStandardTextIssueKind, number> {
|
||||||
|
return {
|
||||||
|
fancy: 0,
|
||||||
|
zalgo: 0,
|
||||||
|
invisible: 0,
|
||||||
|
control: 0,
|
||||||
|
'private-use': 0,
|
||||||
|
unassigned: 0,
|
||||||
|
surrogate: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInRanges(codePoint: number, ranges: ReadonlyArray<readonly [number, number]>) {
|
||||||
|
return ranges.some(([start, end]) => codePoint >= start && codePoint <= end)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVariationSelector(codePoint: number) {
|
||||||
|
return (
|
||||||
|
(codePoint >= 0xfe00 && codePoint <= 0xfe0f) || (codePoint >= 0xe0100 && codePoint <= 0xe01ef)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmojiModifier(codePoint: number) {
|
||||||
|
return codePoint >= 0x1f3fb && codePoint <= 0x1f3ff
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAscii(character: string) {
|
||||||
|
return character.codePointAt(0)! <= 0x7f
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedZeroWidthNonJoiner(
|
||||||
|
characters: readonly string[],
|
||||||
|
characterIndex: number,
|
||||||
|
): boolean {
|
||||||
|
const previous = characters[characterIndex - 1]
|
||||||
|
const next = characters[characterIndex + 1]
|
||||||
|
if (!previous || !next || !LETTER_PATTERN.test(previous) || !LETTER_PATTERN.test(next)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !isAscii(previous) || !isAscii(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function findAdjacentEmojiCharacter(
|
||||||
|
characters: readonly string[],
|
||||||
|
start: number,
|
||||||
|
direction: -1 | 1,
|
||||||
|
): string | undefined {
|
||||||
|
for (let index = start; index >= 0 && index < characters.length; index += direction) {
|
||||||
|
const character = characters[index]
|
||||||
|
const codePoint = character.codePointAt(0)!
|
||||||
|
if (isVariationSelector(codePoint) || isEmojiModifier(codePoint)) continue
|
||||||
|
return character
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedZeroWidthJoiner(characters: readonly string[], characterIndex: number): boolean {
|
||||||
|
const previous = findAdjacentEmojiCharacter(characters, characterIndex - 1, -1)
|
||||||
|
const next = findAdjacentEmojiCharacter(characters, characterIndex + 1, 1)
|
||||||
|
return (
|
||||||
|
previous !== undefined &&
|
||||||
|
next !== undefined &&
|
||||||
|
EXTENDED_PICTOGRAPHIC_PATTERN.test(previous) &&
|
||||||
|
EXTENDED_PICTOGRAPHIC_PATTERN.test(next)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedVariationSelector(
|
||||||
|
characters: readonly string[],
|
||||||
|
characterIndex: number,
|
||||||
|
codePoint: number,
|
||||||
|
): boolean {
|
||||||
|
const previous = characters[characterIndex - 1]
|
||||||
|
if (!previous) return false
|
||||||
|
if (codePoint >= 0xfe00 && codePoint <= 0xfe0f) {
|
||||||
|
return EXTENDED_PICTOGRAPHIC_PATTERN.test(previous) || /^[0-9#*]$/u.test(previous)
|
||||||
|
}
|
||||||
|
return UNIFIED_IDEOGRAPH_PATTERN.test(previous)
|
||||||
|
}
|
||||||
|
|
||||||
|
function codePointLabel(codePoint: number) {
|
||||||
|
return `U+${codePoint.toString(16).toUpperCase().padStart(4, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateNonStandardText(
|
||||||
|
text: string,
|
||||||
|
options: NonStandardTextOptions = {},
|
||||||
|
): NonStandardTextResult {
|
||||||
|
const allowNewlines = options.allowNewlines ?? true
|
||||||
|
const allowTabs = options.allowTabs ?? true
|
||||||
|
const maxCombiningMarks =
|
||||||
|
options.maxCombiningMarksPerCharacter ?? DEFAULT_MAX_COMBINING_MARKS_PER_CHARACTER
|
||||||
|
if (!Number.isInteger(maxCombiningMarks) || maxCombiningMarks < 0) {
|
||||||
|
throw new Error('Maximum combining marks must be a non-negative integer')
|
||||||
|
}
|
||||||
|
|
||||||
|
const issues: NonStandardTextIssue[] = []
|
||||||
|
const counts = createCounts()
|
||||||
|
const characters = Array.from(text)
|
||||||
|
let utf16Index = 0
|
||||||
|
let hasBaseCharacter = false
|
||||||
|
let combiningMarkCount = 0
|
||||||
|
|
||||||
|
function addIssue(
|
||||||
|
kind: NonStandardTextIssueKind,
|
||||||
|
character: string,
|
||||||
|
codePoint: number,
|
||||||
|
index: number,
|
||||||
|
) {
|
||||||
|
issues.push({
|
||||||
|
kind,
|
||||||
|
character,
|
||||||
|
codePoint: codePointLabel(codePoint),
|
||||||
|
index,
|
||||||
|
})
|
||||||
|
counts[kind]++
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let characterIndex = 0; characterIndex < characters.length; characterIndex++) {
|
||||||
|
const character = characters[characterIndex]
|
||||||
|
const codePoint = character.codePointAt(0)!
|
||||||
|
const currentIndex = utf16Index
|
||||||
|
utf16Index += character.length
|
||||||
|
|
||||||
|
if (codePoint >= 0xd800 && codePoint <= 0xdfff) {
|
||||||
|
addIssue('surrogate', character, codePoint, currentIndex)
|
||||||
|
hasBaseCharacter = false
|
||||||
|
combiningMarkCount = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PRIVATE_USE_PATTERN.test(character)) {
|
||||||
|
addIssue('private-use', character, codePoint, currentIndex)
|
||||||
|
} else if (UNASSIGNED_PATTERN.test(character)) {
|
||||||
|
addIssue('unassigned', character, codePoint, currentIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CONTROL_PATTERN.test(character)) {
|
||||||
|
const allowedNewline = allowNewlines && (character === '\n' || character === '\r')
|
||||||
|
const allowedTab = allowTabs && character === '\t'
|
||||||
|
if (!allowedNewline && !allowedTab) {
|
||||||
|
addIssue('control', character, codePoint, currentIndex)
|
||||||
|
}
|
||||||
|
hasBaseCharacter = false
|
||||||
|
combiningMarkCount = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (FORMAT_PATTERN.test(character)) {
|
||||||
|
const allowed =
|
||||||
|
(codePoint === 0x200c && isAllowedZeroWidthNonJoiner(characters, characterIndex)) ||
|
||||||
|
(codePoint === 0x200d && isAllowedZeroWidthJoiner(characters, characterIndex))
|
||||||
|
if (!allowed) addIssue('invisible', character, codePoint, currentIndex)
|
||||||
|
hasBaseCharacter = false
|
||||||
|
combiningMarkCount = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MARK_PATTERN.test(character)) {
|
||||||
|
if (isVariationSelector(codePoint)) {
|
||||||
|
if (!isAllowedVariationSelector(characters, characterIndex, codePoint)) {
|
||||||
|
addIssue('invisible', character, codePoint, currentIndex)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
combiningMarkCount++
|
||||||
|
if (!hasBaseCharacter || combiningMarkCount > maxCombiningMarks) {
|
||||||
|
addIssue('zalgo', character, codePoint, currentIndex)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
combiningMarkCount = 0
|
||||||
|
hasBaseCharacter = !/^\s$/u.test(character)
|
||||||
|
|
||||||
|
if (isInRanges(codePoint, FANCY_RANGES)) {
|
||||||
|
addIssue('fancy', character, codePoint, currentIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: issues.length === 0,
|
||||||
|
issues,
|
||||||
|
counts,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { validateNonStandardText } from './index.ts'
|
||||||
|
|
||||||
|
test('accepts ordinary multilingual text and punctuation', () => {
|
||||||
|
const result = validateNonStandardText(
|
||||||
|
'Hello, “world”! Français — Русский — العربية — 中文 — 日本語',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(result.valid, true)
|
||||||
|
assert.deepEqual(result.issues, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('accepts composed and normally decomposed accents', () => {
|
||||||
|
assert.equal(validateNonStandardText('café').valid, true)
|
||||||
|
assert.equal(validateNonStandardText('cafe\u0301').valid, true)
|
||||||
|
assert.equal(validateNonStandardText('a\u0301\u0327').valid, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detects excessive and leading combining marks as zalgo text', () => {
|
||||||
|
const excessive = validateNonStandardText('a\u0301\u0327\u0308')
|
||||||
|
const leading = validateNonStandardText('\u0301text')
|
||||||
|
|
||||||
|
assert.equal(excessive.valid, false)
|
||||||
|
assert.equal(excessive.counts.zalgo, 1)
|
||||||
|
assert.equal(excessive.issues[0].index, 3)
|
||||||
|
assert.equal(leading.counts.zalgo, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('supports a custom combining-mark threshold', () => {
|
||||||
|
assert.equal(
|
||||||
|
validateNonStandardText('a\u0301\u0327', {
|
||||||
|
maxCombiningMarksPerCharacter: 1,
|
||||||
|
}).counts.zalgo,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
assert.throws(
|
||||||
|
() => validateNonStandardText('text', { maxCombiningMarksPerCharacter: -1 }),
|
||||||
|
/non-negative integer/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detects common fancy alphabets and presentation forms', () => {
|
||||||
|
const result = validateNonStandardText('𝐇 Ⓗ ʰ ℌ h ff')
|
||||||
|
|
||||||
|
assert.equal(result.valid, false)
|
||||||
|
assert.equal(result.counts.fancy, 6)
|
||||||
|
assert.deepEqual(
|
||||||
|
result.issues.map(({ codePoint }) => codePoint),
|
||||||
|
['U+1D407', 'U+24BD', 'U+02B0', 'U+210C', 'U+FF48', 'U+FB00'],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('allows ordinary emoji and valid emoji joiner sequences', () => {
|
||||||
|
assert.equal(validateNonStandardText('Hello 👋🏽').valid, true)
|
||||||
|
assert.equal(validateNonStandardText('Family: 👨👩👧👦').valid, true)
|
||||||
|
assert.equal(validateNonStandardText('Developer: 🧑🏽💻').valid, true)
|
||||||
|
assert.equal(validateNonStandardText('Heart: ❤️').valid, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detects suspicious invisible and directional characters', () => {
|
||||||
|
const result = validateNonStandardText('ab\u200Bcd\u202Eef\u2060gh f\uFE0F')
|
||||||
|
|
||||||
|
assert.equal(result.counts.invisible, 4)
|
||||||
|
assert.deepEqual(
|
||||||
|
result.issues.map(({ codePoint }) => codePoint),
|
||||||
|
['U+200B', 'U+202E', 'U+2060', 'U+FE0F'],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('allows contextual non-joiners but catches ASCII separator evasion', () => {
|
||||||
|
assert.equal(validateNonStandardText('میخواهم').valid, true)
|
||||||
|
assert.equal(validateNonStandardText('fuck').counts.invisible, 1)
|
||||||
|
assert.equal(validateNonStandardText('ab').counts.invisible, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('allows newlines and tabs by default and can reject them', () => {
|
||||||
|
assert.equal(validateNonStandardText('line one\n\tline two').valid, true)
|
||||||
|
|
||||||
|
const result = validateNonStandardText('line one\n\tline two', {
|
||||||
|
allowNewlines: false,
|
||||||
|
allowTabs: false,
|
||||||
|
})
|
||||||
|
assert.equal(result.counts.control, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detects other disallowed control characters', () => {
|
||||||
|
const result = validateNonStandardText(`hello\u0000world`)
|
||||||
|
|
||||||
|
assert.equal(result.counts.control, 1)
|
||||||
|
assert.equal(result.issues[0].codePoint, 'U+0000')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detects private-use, unassigned, and lone surrogate code points', () => {
|
||||||
|
const privateUse = validateNonStandardText('\uE000')
|
||||||
|
const unassigned = validateNonStandardText('\uFDD0')
|
||||||
|
const surrogate = validateNonStandardText('\uD800')
|
||||||
|
|
||||||
|
assert.equal(privateUse.counts['private-use'], 1)
|
||||||
|
assert.equal(unassigned.counts.unassigned, 1)
|
||||||
|
assert.equal(surrogate.counts.surrogate, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reports UTF-16 indexes consistently around astral characters', () => {
|
||||||
|
const result = validateNonStandardText('🙂\u200Btext')
|
||||||
|
|
||||||
|
assert.equal(result.issues[0].index, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reports multiple issue categories in source order', () => {
|
||||||
|
const result = validateNonStandardText('𝐀\u200B\u0000')
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
result.issues.map(({ kind }) => kind),
|
||||||
|
['fancy', 'invisible', 'control'],
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,572 @@
|
|||||||
|
export type ProfanityKind = 'profanity' | 'slur'
|
||||||
|
|
||||||
|
export interface ProfanityPattern {
|
||||||
|
kind: ProfanityKind
|
||||||
|
exceptions: readonly string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfanityConfig {
|
||||||
|
patterns: Readonly<Record<string, ProfanityPattern>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfanityMatch {
|
||||||
|
kind: ProfanityKind
|
||||||
|
term: string
|
||||||
|
sanitizedStart: number
|
||||||
|
sanitizedEnd: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfanityResult {
|
||||||
|
valid: boolean
|
||||||
|
profanityCount: number
|
||||||
|
slurCount: number
|
||||||
|
firstMatch?: ProfanityMatch
|
||||||
|
matches: ProfanityMatch[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfanityValidator {
|
||||||
|
sanitize(text: string): string
|
||||||
|
findFirst(text: string): ProfanityMatch | undefined
|
||||||
|
findAll(text: string): ProfanityMatch[]
|
||||||
|
validate(text: string): ProfanityResult
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NegativeMatch {
|
||||||
|
prefix: string
|
||||||
|
suffix: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrieNode {
|
||||||
|
children: Map<string, TrieNode>
|
||||||
|
negatives: NegativeMatch[]
|
||||||
|
terminal?: {
|
||||||
|
kind: ProfanityKind
|
||||||
|
term: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHARACTER_REPLACEMENTS: Readonly<Record<string, string>> = {
|
||||||
|
'4': 'a',
|
||||||
|
'@': 'a',
|
||||||
|
'3': 'e',
|
||||||
|
'1': 'i',
|
||||||
|
'0': 'o',
|
||||||
|
'5': 's',
|
||||||
|
'7': 't',
|
||||||
|
'8': 'b',
|
||||||
|
'9': 'g',
|
||||||
|
'+': 't',
|
||||||
|
$: 's',
|
||||||
|
'(': 'c',
|
||||||
|
'{': 'c',
|
||||||
|
'[': 'c',
|
||||||
|
'!': 'i',
|
||||||
|
'|': 'i',
|
||||||
|
'£': 'e',
|
||||||
|
'€': 'e',
|
||||||
|
'¥': 'y',
|
||||||
|
'¢': 'c',
|
||||||
|
'<': 'c',
|
||||||
|
}
|
||||||
|
|
||||||
|
const MULTI_CHARACTER_REPLACEMENTS: Readonly<Record<string, Readonly<Record<string, string>>>> = {
|
||||||
|
'(': { ')': 'o' },
|
||||||
|
'[': { ']': 'o' },
|
||||||
|
'{': { '}': 'o' },
|
||||||
|
'<': { '>': 'o' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const TERM_EXCEPTIONS: Readonly<Record<string, readonly string[]>> = {
|
||||||
|
anuslick: [],
|
||||||
|
arsehol: [],
|
||||||
|
arselick: [],
|
||||||
|
asslick: [],
|
||||||
|
arsch: [],
|
||||||
|
asshol: [],
|
||||||
|
auschwitz: [],
|
||||||
|
beaner: [],
|
||||||
|
bestiality: [],
|
||||||
|
baise: [],
|
||||||
|
bakachon: [],
|
||||||
|
bakatyon: [],
|
||||||
|
bastard: ['bastardized'],
|
||||||
|
bitch: [],
|
||||||
|
btch: [],
|
||||||
|
biatch: [],
|
||||||
|
bussy: [],
|
||||||
|
blowjob: [],
|
||||||
|
blowme: [],
|
||||||
|
bukakke: [],
|
||||||
|
buttplug: [],
|
||||||
|
buttchug: [],
|
||||||
|
butagorosi: [],
|
||||||
|
cagada: [],
|
||||||
|
caralho: [],
|
||||||
|
cameljockey: [],
|
||||||
|
castrate: [],
|
||||||
|
cazzo: [],
|
||||||
|
ceemen: [],
|
||||||
|
chankoro: [],
|
||||||
|
chink: [],
|
||||||
|
chingchong: [],
|
||||||
|
choad: [],
|
||||||
|
chode: [],
|
||||||
|
chlamydia: [],
|
||||||
|
clit: ['clitheroe'],
|
||||||
|
clitoris: [],
|
||||||
|
cock: [
|
||||||
|
'cockade',
|
||||||
|
'cockatiel',
|
||||||
|
'cockatiels',
|
||||||
|
'cockatoo',
|
||||||
|
'cockatoos',
|
||||||
|
'cockatrice',
|
||||||
|
'cockayne',
|
||||||
|
'cockburn',
|
||||||
|
'cockcroft',
|
||||||
|
'cocked',
|
||||||
|
'cocker',
|
||||||
|
'cockerel',
|
||||||
|
'cockers',
|
||||||
|
'cockeyed',
|
||||||
|
'cockiness',
|
||||||
|
'cocking',
|
||||||
|
'cocklebur',
|
||||||
|
'cockney',
|
||||||
|
'cockpit',
|
||||||
|
'cockpits',
|
||||||
|
'cockroach',
|
||||||
|
'cockroaches',
|
||||||
|
'cockscomb',
|
||||||
|
'cockspur',
|
||||||
|
'cocktail',
|
||||||
|
'gamecock',
|
||||||
|
'hancock',
|
||||||
|
'haycock',
|
||||||
|
'hitchcock',
|
||||||
|
'leacock',
|
||||||
|
'peacock',
|
||||||
|
'shuttlecock',
|
||||||
|
'stopcock',
|
||||||
|
'woodcock',
|
||||||
|
],
|
||||||
|
coon: ['cocoon', 'laocoon', 'raccoon', 'racoon', 'tycoon'],
|
||||||
|
cocain: [],
|
||||||
|
coitus: [],
|
||||||
|
cottonpic: [],
|
||||||
|
cottonpik: [],
|
||||||
|
cum: [
|
||||||
|
'acumen',
|
||||||
|
'acuminate',
|
||||||
|
'altocumulus',
|
||||||
|
'cumber',
|
||||||
|
'cumbing',
|
||||||
|
'cumbria',
|
||||||
|
'cumbrian',
|
||||||
|
'cumbrous',
|
||||||
|
'cummerbund',
|
||||||
|
'cumming',
|
||||||
|
'cumulat',
|
||||||
|
'cumuli',
|
||||||
|
'cumulonimbus',
|
||||||
|
'cumulus',
|
||||||
|
'encumber',
|
||||||
|
'encumbrance',
|
||||||
|
'scumbag',
|
||||||
|
'locum',
|
||||||
|
'modicum',
|
||||||
|
'magnacumlaude',
|
||||||
|
'macumba',
|
||||||
|
'practicum',
|
||||||
|
'recumbent',
|
||||||
|
'slocum',
|
||||||
|
'stratocumulus',
|
||||||
|
'succumb',
|
||||||
|
'talcum',
|
||||||
|
'taraxacum',
|
||||||
|
'tecumseh',
|
||||||
|
'tucuman',
|
||||||
|
'capsicum',
|
||||||
|
'cecum',
|
||||||
|
'circum',
|
||||||
|
'colchicum',
|
||||||
|
'document',
|
||||||
|
'ecumeni',
|
||||||
|
'illyricum',
|
||||||
|
'incumben',
|
||||||
|
],
|
||||||
|
cunt: ['scunthorpe'],
|
||||||
|
cvnt: [],
|
||||||
|
cunny: [],
|
||||||
|
cunnie: [],
|
||||||
|
csam: [],
|
||||||
|
cyka: [],
|
||||||
|
darkie: [],
|
||||||
|
dick: [
|
||||||
|
'chappaquiddick',
|
||||||
|
'dickens',
|
||||||
|
'dickensian',
|
||||||
|
'dickerson',
|
||||||
|
'dickey',
|
||||||
|
'dickies',
|
||||||
|
'dickinson',
|
||||||
|
'dickson',
|
||||||
|
'dickvandyke',
|
||||||
|
'dicky',
|
||||||
|
],
|
||||||
|
dildo: [],
|
||||||
|
douchebag: [],
|
||||||
|
dyke: ['vandyke'],
|
||||||
|
downie: [],
|
||||||
|
dumbass: [],
|
||||||
|
ejaculate: [],
|
||||||
|
fag: ['antofagasta', 'serfage', 'wharfage', 'fagin', 'leafage'],
|
||||||
|
feck: [],
|
||||||
|
fellate: [],
|
||||||
|
fellatio: [],
|
||||||
|
felch: [],
|
||||||
|
fuck: [],
|
||||||
|
fvck: [],
|
||||||
|
fxck: [],
|
||||||
|
fack: [],
|
||||||
|
fzck: [],
|
||||||
|
fck: [],
|
||||||
|
fudgepacker: [],
|
||||||
|
flange: ['flanged', 'flanges'],
|
||||||
|
gestapo: [],
|
||||||
|
gook: [],
|
||||||
|
horny: ['thorny'],
|
||||||
|
hooker: [],
|
||||||
|
hitler: [],
|
||||||
|
incest: [],
|
||||||
|
jap: ['japan'],
|
||||||
|
jizz: [],
|
||||||
|
jigabo: [],
|
||||||
|
junglebunny: [],
|
||||||
|
kkk: [],
|
||||||
|
kike: [],
|
||||||
|
klux: [],
|
||||||
|
kluklux: [],
|
||||||
|
klukluxklan: [],
|
||||||
|
koon: [],
|
||||||
|
lickmy: [],
|
||||||
|
masturbat: [],
|
||||||
|
molest: [],
|
||||||
|
muff: [
|
||||||
|
'muffed',
|
||||||
|
'muffin',
|
||||||
|
'muffins',
|
||||||
|
'muffle',
|
||||||
|
'muffled',
|
||||||
|
'muffler',
|
||||||
|
'mufflers',
|
||||||
|
'muffles',
|
||||||
|
'muffling',
|
||||||
|
'muffs',
|
||||||
|
'ragamuffin',
|
||||||
|
'earmuff',
|
||||||
|
'earmuffs',
|
||||||
|
],
|
||||||
|
nazi: ['ashkenazi', 'ashkenazic', 'ashkenazim', 'monazite'],
|
||||||
|
nigg: [],
|
||||||
|
niqa: [],
|
||||||
|
nigga: [],
|
||||||
|
niqqa: [],
|
||||||
|
niggu: [],
|
||||||
|
niqqu: [],
|
||||||
|
niggr: [],
|
||||||
|
nigger: [],
|
||||||
|
niglet: [],
|
||||||
|
nignog: [],
|
||||||
|
paki: ['pakistan'],
|
||||||
|
penis: ['openis', 'penistone'],
|
||||||
|
porn: [],
|
||||||
|
prostitut: [],
|
||||||
|
pube: [],
|
||||||
|
pussie: [],
|
||||||
|
pussy: ['pussycat', 'pussyfoot'],
|
||||||
|
raghead: [],
|
||||||
|
rape: [
|
||||||
|
'grape',
|
||||||
|
'forape',
|
||||||
|
'trapeze',
|
||||||
|
'trapezium',
|
||||||
|
'trapezius',
|
||||||
|
'trapezoid',
|
||||||
|
'therapeutic',
|
||||||
|
'drape',
|
||||||
|
'parapet',
|
||||||
|
'rapeseed',
|
||||||
|
'scrape',
|
||||||
|
'serape',
|
||||||
|
],
|
||||||
|
rapist: ['therapist'],
|
||||||
|
retard: ['retardant', 'retarder', 'retarding'],
|
||||||
|
rimjob: [],
|
||||||
|
shit: [
|
||||||
|
'cushitic',
|
||||||
|
'shitake',
|
||||||
|
'pushit',
|
||||||
|
'peshitta',
|
||||||
|
'libshitz',
|
||||||
|
'shitzu',
|
||||||
|
'wishit',
|
||||||
|
'yamashita',
|
||||||
|
'finishit',
|
||||||
|
'shitbox',
|
||||||
|
'shitmg',
|
||||||
|
'publishit',
|
||||||
|
'englishit',
|
||||||
|
],
|
||||||
|
slut: [],
|
||||||
|
spunk: ['spunky'],
|
||||||
|
suckmy: [],
|
||||||
|
sodom: [],
|
||||||
|
semen: ['sement'],
|
||||||
|
teensex: [],
|
||||||
|
tittie: [],
|
||||||
|
titty: [],
|
||||||
|
trannie: [],
|
||||||
|
tranny: [],
|
||||||
|
vagina: [],
|
||||||
|
wank: ['swank', 'wankel'],
|
||||||
|
wetback: [],
|
||||||
|
whore: ['whores', 'whorev', 'whoreturned'],
|
||||||
|
whitepower: [],
|
||||||
|
fondle: [],
|
||||||
|
minestorm: [],
|
||||||
|
kissmy: [],
|
||||||
|
blowmy: [],
|
||||||
|
jelqing: [],
|
||||||
|
dafuq: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLUR_TERMS = new Set([
|
||||||
|
'beaner',
|
||||||
|
'cameljockey',
|
||||||
|
'chankoro',
|
||||||
|
'chink',
|
||||||
|
'chingchong',
|
||||||
|
'coon',
|
||||||
|
'cottonpic',
|
||||||
|
'cottonpik',
|
||||||
|
'darkie',
|
||||||
|
'downie',
|
||||||
|
'dyke',
|
||||||
|
'fag',
|
||||||
|
'gook',
|
||||||
|
'jap',
|
||||||
|
'jigabo',
|
||||||
|
'junglebunny',
|
||||||
|
'kike',
|
||||||
|
'koon',
|
||||||
|
'nigg',
|
||||||
|
'niqa',
|
||||||
|
'nigga',
|
||||||
|
'niqqa',
|
||||||
|
'niggu',
|
||||||
|
'niqqu',
|
||||||
|
'niggr',
|
||||||
|
'nigger',
|
||||||
|
'niglet',
|
||||||
|
'nignog',
|
||||||
|
'paki',
|
||||||
|
'raghead',
|
||||||
|
'retard',
|
||||||
|
'trannie',
|
||||||
|
'tranny',
|
||||||
|
'wetback',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const DEFAULT_PROFANITY_PATTERNS: Readonly<Record<string, ProfanityPattern>> =
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(TERM_EXCEPTIONS).map(([term, exceptions]) => {
|
||||||
|
const kind: ProfanityKind = SLUR_TERMS.has(term) ? 'slur' : 'profanity'
|
||||||
|
return [term, { kind, exceptions }] as const
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const DEFAULT_PROFANITY_CONFIG: ProfanityConfig = {
|
||||||
|
patterns: DEFAULT_PROFANITY_PATTERNS,
|
||||||
|
}
|
||||||
|
|
||||||
|
function newTrieNode(): TrieNode {
|
||||||
|
return {
|
||||||
|
children: new Map(),
|
||||||
|
negatives: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeProfanityText(text: string): string {
|
||||||
|
const transformed = text
|
||||||
|
.normalize('NFD')
|
||||||
|
.replaceAll(/\p{Mn}/gu, '')
|
||||||
|
.normalize('NFC')
|
||||||
|
const characters = Array.from(transformed)
|
||||||
|
let sanitized = ''
|
||||||
|
|
||||||
|
for (let index = 0; index < characters.length; index++) {
|
||||||
|
const character = characters[index]
|
||||||
|
const multiReplacement = MULTI_CHARACTER_REPLACEMENTS[character]
|
||||||
|
const nextCharacter = characters[index + 1]
|
||||||
|
|
||||||
|
if (multiReplacement && nextCharacter && multiReplacement[nextCharacter]) {
|
||||||
|
sanitized += multiReplacement[nextCharacter]
|
||||||
|
index++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const replacement = CHARACTER_REPLACEMENTS[character]
|
||||||
|
if (replacement) {
|
||||||
|
sanitized += replacement
|
||||||
|
} else if (character >= 'A' && character <= 'Z') {
|
||||||
|
sanitized += character.toLowerCase()
|
||||||
|
} else if ((character >= 'a' && character <= 'z') || (character >= '0' && character <= '9')) {
|
||||||
|
sanitized += character
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTrie(patterns: Readonly<Record<string, ProfanityPattern>>): TrieNode {
|
||||||
|
const root = newTrieNode()
|
||||||
|
|
||||||
|
for (const [rawTerm, pattern] of Object.entries(patterns)) {
|
||||||
|
const term = rawTerm.toLowerCase()
|
||||||
|
if (!term || sanitizeProfanityText(term) !== term) {
|
||||||
|
throw new Error(`Profanity term must already be sanitized: ${rawTerm}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = root
|
||||||
|
for (const character of term) {
|
||||||
|
let child = current.children.get(character)
|
||||||
|
if (!child) {
|
||||||
|
child = newTrieNode()
|
||||||
|
current.children.set(character, child)
|
||||||
|
}
|
||||||
|
current = child
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.terminal) {
|
||||||
|
throw new Error(`Duplicate sanitized profanity term: ${term}`)
|
||||||
|
}
|
||||||
|
current.terminal = { kind: pattern.kind, term }
|
||||||
|
|
||||||
|
for (const rawException of pattern.exceptions) {
|
||||||
|
const exception = rawException.toLowerCase()
|
||||||
|
if (sanitizeProfanityText(exception) !== exception) {
|
||||||
|
throw new Error(`Profanity exception must already be sanitized: ${rawException}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const termIndex = exception.indexOf(term)
|
||||||
|
if (termIndex < 0) {
|
||||||
|
throw new Error(`Profanity exception must contain ${term}: ${rawException}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
current.negatives.push({
|
||||||
|
prefix: exception.slice(0, termIndex),
|
||||||
|
suffix: exception.slice(termIndex + term.length),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
function negativeMatches(negative: NegativeMatch, text: string, start: number, end: number) {
|
||||||
|
const prefixIndex = start - negative.prefix.length
|
||||||
|
const suffixIndex = end + negative.suffix.length
|
||||||
|
|
||||||
|
if (prefixIndex < 0 || suffixIndex > text.length) return false
|
||||||
|
return (
|
||||||
|
text.slice(prefixIndex, start) === negative.prefix &&
|
||||||
|
text.slice(end, suffixIndex) === negative.suffix
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function findAt(root: TrieNode, text: string, start: number): ProfanityMatch | undefined {
|
||||||
|
let current = root
|
||||||
|
|
||||||
|
for (let index = start; index < text.length; index++) {
|
||||||
|
const child = current.children.get(text[index])
|
||||||
|
if (!child) return undefined
|
||||||
|
current = child
|
||||||
|
|
||||||
|
if (!current.terminal) continue
|
||||||
|
|
||||||
|
const end = index + 1
|
||||||
|
const matchesNegative = current.negatives.some((negative) =>
|
||||||
|
negativeMatches(negative, text, start, end),
|
||||||
|
)
|
||||||
|
if (matchesNegative && current.children.size === 0) return undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: current.terminal.kind,
|
||||||
|
term: text.slice(start, end),
|
||||||
|
sanitizedStart: start,
|
||||||
|
sanitizedEnd: end,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProfanityValidator(
|
||||||
|
config: ProfanityConfig = DEFAULT_PROFANITY_CONFIG,
|
||||||
|
): ProfanityValidator {
|
||||||
|
const root = createTrie(config.patterns)
|
||||||
|
|
||||||
|
function findFirst(text: string): ProfanityMatch | undefined {
|
||||||
|
const sanitized = sanitizeProfanityText(text)
|
||||||
|
for (let index = 0; index < sanitized.length; index++) {
|
||||||
|
const match = findAt(root, sanitized, index)
|
||||||
|
if (match) return match
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function findAll(text: string): ProfanityMatch[] {
|
||||||
|
const sanitized = sanitizeProfanityText(text)
|
||||||
|
const matches: ProfanityMatch[] = []
|
||||||
|
|
||||||
|
for (let index = 0; index < sanitized.length; ) {
|
||||||
|
const match = findAt(root, sanitized, index)
|
||||||
|
if (match) {
|
||||||
|
matches.push(match)
|
||||||
|
index = match.sanitizedEnd
|
||||||
|
} else {
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate(text: string): ProfanityResult {
|
||||||
|
const matches = findAll(text)
|
||||||
|
const profanityCount = matches.filter((match) => match.kind === 'profanity').length
|
||||||
|
const slurCount = matches.length - profanityCount
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: matches.length === 0,
|
||||||
|
profanityCount,
|
||||||
|
slurCount,
|
||||||
|
firstMatch: matches[0],
|
||||||
|
matches,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sanitize: sanitizeProfanityText,
|
||||||
|
findFirst,
|
||||||
|
findAll,
|
||||||
|
validate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const profanityValidator = createProfanityValidator()
|
||||||
|
|
||||||
|
export function validateProfanity(text: string): ProfanityResult {
|
||||||
|
return profanityValidator.validate(text)
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { createProfanityValidator, sanitizeProfanityText, validateProfanity } from './index.ts'
|
||||||
|
|
||||||
|
test('sanitizes text with the configured single-character replacements', () => {
|
||||||
|
assert.equal(sanitizeProfanityText('4@3105789+$([{!|£€¥¢<'), 'aaeiostbgtsccciieeycc')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sanitizes paired characters before their single-character replacements', () => {
|
||||||
|
assert.equal(sanitizeProfanityText('()[]{}<>'), 'oooo')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('strips accents, separators, emoji, and ASCII casing', () => {
|
||||||
|
assert.equal(sanitizeProfanityText('F Ü.C—K🙂'), 'fuck')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('matches profanity across separators and common substitutions', () => {
|
||||||
|
assert.equal(validateProfanity('f.u c-k').firstMatch?.term, 'fuck')
|
||||||
|
assert.equal(validateProfanity('$h!t').firstMatch?.term, 'shit')
|
||||||
|
assert.equal(validateProfanity('p()rn').firstMatch?.term, 'porn')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('honors exact negative prefix and suffix matches', () => {
|
||||||
|
const validator = createProfanityValidator({
|
||||||
|
patterns: {
|
||||||
|
bad: { kind: 'profanity', exceptions: ['notbadword'] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(validator.findFirst('not bad word'), undefined)
|
||||||
|
assert.equal(validator.findFirst('very bad word')?.term, 'bad')
|
||||||
|
assert.equal(validator.findFirst('not bad phrase')?.term, 'bad')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('matches the first profanity while ignoring a later negative match', () => {
|
||||||
|
const validator = createProfanityValidator({
|
||||||
|
patterns: {
|
||||||
|
shit: { kind: 'profanity', exceptions: ['horseshit', 'bullshit'] },
|
||||||
|
fuck: { kind: 'profanity', exceptions: [] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(validator.findFirst('this horseshit'), undefined)
|
||||||
|
assert.equal(validator.findFirst('fuck this bullshit')?.term, 'fuck')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('uses the first terminal when one configured term prefixes another', () => {
|
||||||
|
const validator = createProfanityValidator({
|
||||||
|
patterns: {
|
||||||
|
bad: { kind: 'profanity', exceptions: [] },
|
||||||
|
badword: { kind: 'profanity', exceptions: [] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(validator.findFirst('badword')?.term, 'bad')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not match configured false-positive substrings', () => {
|
||||||
|
assert.equal(validateProfanity('Scunthorpe and peacock').valid, true)
|
||||||
|
assert.equal(validateProfanity('cock and cunt').profanityCount, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects any uncensored configured profanity', () => {
|
||||||
|
assert.equal(validateProfanity('A clean project').valid, true)
|
||||||
|
assert.equal(validateProfanity('This is shit').valid, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('allows redacted profanity when the removed letters cannot reconstruct a term', () => {
|
||||||
|
assert.equal(validateProfanity('f**k').valid, true)
|
||||||
|
assert.equal(validateProfanity('f**k works in titles, summaries, and descriptions').valid, true)
|
||||||
|
assert.equal(validateProfanity('f.u.c.k').valid, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects slurs', () => {
|
||||||
|
const validator = createProfanityValidator({
|
||||||
|
patterns: {
|
||||||
|
forbidden: { kind: 'slur', exceptions: [] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(validator.validate('forbidden').valid, false)
|
||||||
|
assert.equal(validator.validate('forbidden').slurCount, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('counts matches from left to right without overlaps', () => {
|
||||||
|
const validator = createProfanityValidator({
|
||||||
|
patterns: {
|
||||||
|
bad: { kind: 'profanity', exceptions: [] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
validator
|
||||||
|
.findAll('bad-bad')
|
||||||
|
.map(({ sanitizedStart, sanitizedEnd }) => [sanitizedStart, sanitizedEnd]),
|
||||||
|
[
|
||||||
|
[0, 3],
|
||||||
|
[3, 6],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects invalid configuration', () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createProfanityValidator({
|
||||||
|
patterns: {
|
||||||
|
'not sanitized': { kind: 'profanity', exceptions: [] },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/term must already be sanitized/,
|
||||||
|
)
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createProfanityValidator({
|
||||||
|
patterns: {
|
||||||
|
bad: { kind: 'profanity', exceptions: ['innocent'] },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/exception must contain bad/,
|
||||||
|
)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user