feat: polish profanity validation

This commit is contained in:
tdgao
2026-08-26 16:20:35 -06:00
parent 89b392dc3d
commit ae4519cc26
13 changed files with 435 additions and 576 deletions
@@ -56,7 +56,7 @@
<div
v-for="nag in visibleNags"
:key="nag.id"
class="flex w-72 shrink-0 flex-col gap-3 rounded-2xl border border-solid border-surface-5 bg-surface-2 p-4"
class="flex w-[268px] shrink-0 flex-col gap-3 rounded-2xl border border-solid border-surface-5 bg-surface-2 p-4"
>
<span class="flex items-center gap-2 font-medium text-contrast">
<component
+77 -37
View File
@@ -171,6 +171,7 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
CopyIcon,
EditIcon,
ListFilterIcon,
ScaleIcon,
@@ -207,14 +208,15 @@ import { useModerationQueue } from '~/services/moderation/queue.ts'
import { findNextEligibleQueueProject } from '~/services/moderation/queue-eligibility.ts'
import {
fetchAllModerationQueueProjects,
scanProjectsWithValidationErrors,
scanProjectsWithValidationIssues,
type ValidationFilterRequest,
} from '~/services/moderation/validation-filter.ts'
useHead({ title: 'Projects queue - Modrinth' })
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const notificationManager = injectNotificationManager()
const { addNotification } = notificationManager
const moderationQueue = useModerationQueue()
const route = useRoute()
const router = useRouter()
@@ -306,6 +308,7 @@ const filterTypes: ComboboxOption<string>[] = [
{ value: 'Servers', label: 'Servers' },
{ value: 'Project IDs', label: 'Project IDs' },
{ value: 'Validation errors', label: 'Validation errors' },
{ value: 'Validation errors + warnings', label: 'Validation errors + warnings' },
{ value: 'Fucked up', label: 'Fucked up' },
]
const filterTypeValues = filterTypes.map((option) => option.value)
@@ -314,6 +317,7 @@ const DEFAULT_FILTER_TYPE = filterTypeValues[0]
const MODPACK_FILTER_TYPE = 'Modpacks'
const PROJECT_IDS_FILTER_TYPE = 'Project IDs'
const VALIDATION_ERROR_FILTER_TYPE = 'Validation errors'
const VALIDATION_ERROR_AND_WARNING_FILTER_TYPE = 'Validation errors + warnings'
const VALIDATION_FILTER_STALE_TIME_MS = 1000 * 60 * 5
const baseSortTypes: ComboboxOption<string>[] = [
@@ -516,6 +520,17 @@ const moderationProjectsQueryKey = computed(
() => ['moderation-projects', moderationProjectsRequest.value] as const,
)
const isValidationErrorFilter = computed(
() => currentFilterType.value === VALIDATION_ERROR_FILTER_TYPE,
)
const isValidationErrorAndWarningFilter = computed(
() => currentFilterType.value === VALIDATION_ERROR_AND_WARNING_FILTER_TYPE,
)
const isValidationFilter = computed(
() => isValidationErrorFilter.value || isValidationErrorAndWarningFilter.value,
)
const isProjectIdsFilter = computed(() => currentFilterType.value === PROJECT_IDS_FILTER_TYPE)
const {
data: standardProjectsResponse,
isPending: standardProjectsPending,
@@ -525,11 +540,7 @@ const {
queryKey: moderationProjectsQueryKey,
queryFn: ({ queryKey }) => client.labrinth.moderation_internal.getProjects(queryKey[1]),
placeholderData: (previousData) => previousData,
enabled: computed(
() =>
currentFilterType.value !== VALIDATION_ERROR_FILTER_TYPE &&
currentFilterType.value !== PROJECT_IDS_FILTER_TYPE,
),
enabled: computed(() => !isValidationFilter.value && !isProjectIdsFilter.value),
})
const validationFilterRequest = computed<ValidationFilterRequest>(() => ({
@@ -539,45 +550,80 @@ const validationFilterRequest = computed<ValidationFilterRequest>(() => ({
}))
const validationProjectsQueryKey = computed(
() => ['moderation-projects', 'validation-errors', validationFilterRequest.value] as const,
() =>
[
'moderation-projects',
'validation',
isValidationErrorAndWarningFilter.value,
validationFilterRequest.value,
] as const,
)
let validationScanNotificationId: string | number | undefined
function showValidationScanCompleteNotification(
response: Labrinth.Moderation.Internal.ProjectsResponse,
includeWarnings: boolean,
) {
if (validationScanNotificationId !== undefined) {
notificationManager.removeNotification(validationScanNotificationId)
}
const projectIds = response.projects.map((project) => project.id)
const notification = addNotification({
title: 'Validation scan complete',
text: `Found ${response.total} projects with validation ${includeWarnings ? 'errors or warnings' : 'errors'}.`,
type: 'success',
autoCloseMs: null,
copyable: false,
buttons: [
{
label: 'Copy all IDs',
icon: CopyIcon,
keepOpen: true,
action: () => navigator.clipboard.writeText(projectIds.join('\n')),
},
],
})
validationScanNotificationId = notification.id
}
const {
data: validationProjectsResponse,
isPending: validationProjectsPending,
error: validationProjectsError,
} = useQuery({
queryKey: validationProjectsQueryKey,
queryFn: ({ queryKey, signal }) =>
scanProjectsWithValidationErrors({
queryFn: async ({ queryKey, signal }) => {
const response = await scanProjectsWithValidationIssues({
client,
request: queryKey[2],
request: queryKey[3],
titleMetadata: {
gameVersions: generatedState.value.gameVersions.map(({ version }) => version),
loaders: generatedState.value.loaders.map(({ name }) => name),
},
includeWarnings: queryKey[2],
signal,
log: debugValidationFilter,
}),
enabled: computed(
() => import.meta.client && currentFilterType.value === VALIDATION_ERROR_FILTER_TYPE,
),
})
showValidationScanCompleteNotification(response, queryKey[2])
return response
},
enabled: computed(() => import.meta.client && isValidationFilter.value),
staleTime: VALIDATION_FILTER_STALE_TIME_MS,
retry: false,
})
watch(
() => currentFilterType.value === VALIDATION_ERROR_FILTER_TYPE,
(isValidationFilter) => {
if (!isValidationFilter) return
const cached = queryClient.getQueryData<Labrinth.Moderation.Internal.ProjectsResponse>(
validationProjectsQueryKey.value,
)
if (cached) {
debugValidationFilter(`Using cached scan result with ${cached.total} matching projects`)
}
},
)
watch([isValidationFilter, validationProjectsQueryKey], ([isActive]) => {
if (!isActive) return
const cached = queryClient.getQueryData<Labrinth.Moderation.Internal.ProjectsResponse>(
validationProjectsQueryKey.value,
)
if (cached) {
debugValidationFilter(`Using cached scan result with ${cached.total} matching projects`)
showValidationScanCompleteNotification(cached, isValidationErrorAndWarningFilter.value)
}
})
const projectIdsFilterRequest = computed<ValidationFilterRequest>(() => ({
exclude_technical_review: excludeTechnicalReview.value,
@@ -640,29 +686,23 @@ const {
retry: false,
})
const isValidationErrorFilter = computed(
() => currentFilterType.value === VALIDATION_ERROR_FILTER_TYPE,
)
const isProjectIdsFilter = computed(() => currentFilterType.value === PROJECT_IDS_FILTER_TYPE)
const usesLocalPagination = computed(
() => isValidationErrorFilter.value || isProjectIdsFilter.value,
)
const usesLocalPagination = computed(() => isValidationFilter.value || isProjectIdsFilter.value)
const moderationProjectsResponse = computed(() =>
isValidationErrorFilter.value
isValidationFilter.value
? validationProjectsResponse.value
: isProjectIdsFilter.value
? projectIdsProjectsResponse.value
: standardProjectsResponse.value,
)
const pending = computed(() =>
isValidationErrorFilter.value
isValidationFilter.value
? validationProjectsPending.value
: isProjectIdsFilter.value
? projectIds.value.length > 0 && projectIdsProjectsPending.value
: standardProjectsPending.value || standardProjectsPlaceholder.value,
)
const loadError = computed(() =>
isValidationErrorFilter.value
isValidationFilter.value
? validationProjectsError.value
: isProjectIdsFilter.value
? projectIdsProjectsError.value
@@ -1,5 +1,5 @@
import type { AbstractModrinthClient, Labrinth } from '@modrinth/api-client'
import { hasProjectFieldValidationFailures, type ProjectTitleMetadata } from '@modrinth/moderation'
import { type ProjectTitleMetadata, validateProjectFields } from '@modrinth/moderation'
export type ValidationFilterRequest = Omit<
Labrinth.Moderation.Internal.ProjectsRequest,
@@ -17,6 +17,7 @@ interface ValidationFilterScanOptions {
client: AbstractModrinthClient
request: ValidationFilterRequest
titleMetadata: ProjectTitleMetadata
includeWarnings: boolean
signal: AbortSignal
log: (message: string) => void
}
@@ -126,10 +127,11 @@ export async function fetchAllModerationQueueProjects(
}
}
export async function scanProjectsWithValidationErrors({
export async function scanProjectsWithValidationIssues({
client,
request,
titleMetadata,
includeWarnings,
signal,
log,
}: ValidationFilterScanOptions): Promise<Labrinth.Moderation.Internal.ProjectsResponse> {
@@ -168,7 +170,8 @@ export async function scanProjectsWithValidationErrors({
if (!project) {
throw new Error(`V3 projects response omitted queued project ${projectId}`)
}
if (hasProjectFieldValidationFailures(project, titleMetadata)) {
const validation = validateProjectFields(project, titleMetadata)
if (includeWarnings ? validation.failures.length > 0 : !validation.valid) {
matchingProjectIds.add(projectId)
}
}
+1
View File
@@ -16,6 +16,7 @@
"@modrinth/utils": "workspace:*",
"@modrinth/api-client": "workspace:*",
"linkify-it": "^5.0.0",
"obscenity": "^0.4.6",
"vue": "^3.5.13"
},
"devDependencies": {
@@ -357,10 +357,10 @@
"defaultMessage": "Non-standard text characters are not allowed."
},
"project.text-validation.profanity": {
"defaultMessage": "Profanity is not allowed."
"defaultMessage": "The detected profanity “{value}” is not allowed."
},
"project.text-validation.slur": {
"defaultMessage": "Slurs are not allowed."
"defaultMessage": "The detected slur “{value}” is not allowed."
},
"project.text-validation.summary-link": {
"defaultMessage": "Links should not be included in project summaries."
@@ -2,7 +2,7 @@
export type NonStandardTextIssueKind =
| 'fancy' // styled characters such as `𝐀`, `Ⓐ`, or ``.
| 'zalgo' // detached or excessive combining marks such as `a̴̵̶`.
| 'invisible' // hidden formatting such as a zero-width space or bidi override.
| 'invisible' // hidden formatting such as a word joiner 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.
@@ -44,7 +44,9 @@ const FANCY_RANGES: ReadonlyArray<readonly [number, number]> = [
[0x2070, 0x209f],
[0x2100, 0x214f],
[0xfb00, 0xfb06],
[0xff01, 0xff60],
[0xff10, 0xff19],
[0xff21, 0xff3a],
[0xff41, 0xff5a],
[0x1f100, 0x1f1ad],
]
@@ -230,6 +232,7 @@ export function validateNonStandardText(
if (FORMAT_PATTERN.test(character)) {
const allowed =
codePoint === 0x200b ||
(codePoint === 0x200c && isAllowedZeroWidthNonJoiner(characters, characterIndex)) ||
(codePoint === 0x200d && isAllowedZeroWidthJoiner(characters, characterIndex)) ||
(isEmojiTag(codePoint) && isAllowedEmojiTagSequence(characters, characterIndex))
@@ -257,7 +260,11 @@ export function validateNonStandardText(
combiningMarkCount = 0
hasBaseCharacter = !/^\s$/u.test(character)
if (isInRanges(codePoint, FANCY_RANGES) && !isPresentedAsEmoji(characters, characterIndex)) {
if (
codePoint !== 0x2122 &&
isInRanges(codePoint, FANCY_RANGES) &&
!isPresentedAsEmoji(characters, characterIndex)
) {
addIssue('fancy', character, codePoint, currentIndex)
}
}
@@ -5,7 +5,16 @@ import { getNonStandardTextRatio, validateNonStandardText } from './index.ts'
test('accepts ordinary multilingual text and punctuation', () => {
const result = validateNonStandardText(
'Hello, “world”! Français — Русский — العربية — 中文 — 日本語',
'Hello, “world”! Français — Русский — العربية — 中文 — 日本語',
)
assert.equal(result.valid, true)
assert.deepEqual(result.issues, [])
})
test('accepts Chinese text with fullwidth punctuation', () => {
const result = validateNonStandardText(
'这是一个中文项目,支持简体和繁體中文!请查看说明:性能、兼容性(支持 1.21)。',
)
assert.equal(result.valid, true)
@@ -42,13 +51,13 @@ test('supports a custom combining-mark threshold', () => {
})
test('detects common fancy alphabets and presentation forms', () => {
const result = validateNonStandardText('𝐇 Ⓗ ʰ ff')
const result = validateNonStandardText('𝐇 Ⓗ ʰ ff')
assert.equal(result.valid, false)
assert.equal(result.counts.fancy, 6)
assert.equal(result.counts.fancy, 7)
assert.deepEqual(
result.issues.map(({ codePoint }) => codePoint),
['U+1D407', 'U+24BD', 'U+02B0', 'U+210C', 'U+FF48', 'U+FB00'],
['U+1D407', 'U+24BD', 'U+02B0', 'U+210C', 'U+FF48', 'U+FF11', 'U+FB00'],
)
})
@@ -62,13 +71,22 @@ test('allows ordinary emoji and valid emoji joiner sequences', () => {
assert.equal(validateNonStandardText('Scotland: 🏴󠁧󠁢󠁳󠁣󠁴󠁿').valid, true)
})
test('allows zero-width spaces used as formatting residue', () => {
const result = validateNonStandardText(
'Clan System: Bank - Ranks - Languages \u200B\u200B- Vault - Management',
)
assert.equal(result.valid, true)
assert.deepEqual(result.issues, [])
})
test('detects suspicious invisible and directional characters', () => {
const result = validateNonStandardText('ab\u200Bcd\u202Eef\u2060gh f\uFE0F')
const result = validateNonStandardText('ab\u2060cd\u202Eef\u2063gh f\uFE0F')
assert.equal(result.counts.invisible, 4)
assert.deepEqual(
result.issues.map(({ codePoint }) => codePoint),
['U+200B', 'U+202E', 'U+2060', 'U+FE0F'],
['U+2060', 'U+202E', 'U+2063', 'U+FE0F'],
)
})
@@ -106,13 +124,13 @@ test('detects private-use, unassigned, and lone surrogate code points', () => {
})
test('reports UTF-16 indexes consistently around astral characters', () => {
const result = validateNonStandardText('🙂\u200Btext')
const result = validateNonStandardText('🙂\u2060text')
assert.equal(result.issues[0].index, 2)
})
test('reports multiple issue categories in source order', () => {
const result = validateNonStandardText('𝐀\u200B\u0000')
const result = validateNonStandardText('𝐀\u2060\u0000')
assert.deepEqual(
result.issues.map(({ kind }) => kind),
@@ -1,8 +1,17 @@
import {
collapseDuplicatesTransformer,
parseRawPattern,
RegExpMatcher,
resolveConfusablesTransformer,
resolveLeetSpeakTransformer,
skipNonAlphabeticTransformer,
toAsciiLowerCaseTransformer,
} from 'obscenity'
export type ProfanityKind = 'profanity' | 'slur'
export interface ProfanityPattern {
kind: ProfanityKind
exceptions: readonly string[]
}
export interface ProfanityConfig {
@@ -12,8 +21,9 @@ export interface ProfanityConfig {
export interface ProfanityMatch {
kind: ProfanityKind
term: string
sanitizedStart: number
sanitizedEnd: number
rawText: string
start: number
end: number
}
export interface ProfanityResult {
@@ -25,321 +35,148 @@ export interface ProfanityResult {
}
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 TERMS = [
'anuslick',
'arsehol',
'arselick',
'asslick',
'arsch',
'asshol',
'auschwitz',
'beaner',
'bestiality',
'baise',
'bakachon',
'bakatyon',
'bastard',
'bitch',
'btch',
'biatch',
'bussy',
'blowjob',
'blowme',
'bukakke',
'buttplug',
'buttchug',
'butagorosi',
'cagada',
'caralho',
'cameljockey',
'castrate',
'cazzo',
'ceemen',
'chankoro',
'chink',
'chingchong',
'choad',
'chode',
'chlamydia',
'clit',
'clitoris',
'cock',
'coon',
'cocain',
'coitus',
'cottonpic',
'cottonpik',
'cum',
'cunt',
'cvnt',
'cunny',
'cunnie',
'csam',
'cyka',
'darkie',
'dick',
'dildo',
'douchebag',
'dyke',
'downie',
'dumbass',
'ejaculate',
'fag',
'feck',
'fellate',
'fellatio',
'felch',
'fuck',
'fvck',
'fxck',
'fack',
'fzck',
'fck',
'fudgepacker',
'flange',
'gestapo',
'gook',
'horny',
'hooker',
'hitler',
'incest',
'jap',
'jizz',
'jigabo',
'junglebunny',
'kkk',
'kike',
'klux',
'kluklux',
'klukluxklan',
'koon',
'lickmy',
'masturbat',
'molest',
'muff',
'nazi',
'nigger',
'nigga',
'niqa',
'niqqa',
'niggu',
'niqqu',
'niggr',
'niglet',
'nignog',
'paki',
'penis',
'porn',
'prostitut',
'pube',
'pussie',
'pussy',
'raghead',
'rape',
'rapist',
'retard',
'rimjob',
'shit',
'slut',
'spunk',
'suckmy',
'sodom',
'semen',
'teensex',
'tittie',
'titty',
'trannie',
'tranny',
'vagina',
'wank',
'wetback',
'whore',
'whitepower',
'fondle',
'minestorm',
'kissmy',
'blowmy',
'jelqing',
'dafuq',
] as const
const SLUR_TERMS = new Set([
'beaner',
@@ -380,9 +217,9 @@ const SLUR_TERMS = new Set([
export const DEFAULT_PROFANITY_PATTERNS: Readonly<Record<string, ProfanityPattern>> =
Object.fromEntries(
Object.entries(TERM_EXCEPTIONS).map(([term, exceptions]) => {
TERMS.map((term) => {
const kind: ProfanityKind = SLUR_TERMS.has(term) ? 'slur' : 'profanity'
return [term, { kind, exceptions }] as const
return [term, { kind }] as const
}),
)
@@ -390,159 +227,69 @@ export const DEFAULT_PROFANITY_CONFIG: ProfanityConfig = {
patterns: DEFAULT_PROFANITY_PATTERNS,
}
function newTrieNode(): TrieNode {
return {
children: new Map(),
negatives: [],
}
}
function getDuplicateThresholds(terms: readonly string[]): Map<string, number> {
const thresholds = new Map<string, number>()
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 (const term of terms) {
let runLength = 0
let previousCharacter = ''
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),
})
runLength = character === previousCharacter ? runLength + 1 : 1
previousCharacter = character
thresholds.set(character, Math.max(thresholds.get(character) ?? 1, runLength))
}
}
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) continue
return {
kind: current.terminal.kind,
term: text.slice(start, end),
sanitizedStart: start,
sanitizedEnd: end,
}
}
return undefined
return thresholds
}
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
const entries = Object.entries(config.patterns).map(([rawTerm, pattern]) => {
const term = rawTerm.toLowerCase()
if (!term || !/^[a-z]+$/.test(term)) {
throw new Error(`Profanity term must contain only ASCII letters: ${rawTerm}`)
}
return undefined
}
return { kind: pattern.kind, term }
})
const matcher = new RegExpMatcher({
blacklistedTerms: entries.map(({ term }, id) => ({ id, pattern: parseRawPattern(term) })),
blacklistMatcherTransformers: [
resolveConfusablesTransformer(),
resolveLeetSpeakTransformer(),
toAsciiLowerCaseTransformer(),
skipNonAlphabeticTransformer(),
collapseDuplicatesTransformer({
customThresholds: getDuplicateThresholds(entries.map(({ term }) => term)),
}),
],
})
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++
}
for (const match of matcher.getAllMatches(text, true)) {
const profanityPattern = entries[match.termId]
if (!profanityPattern || match.startIndex < (matches.at(-1)?.end ?? 0)) continue
matches.push({
...profanityPattern,
rawText: text.slice(match.startIndex, match.endIndex + 1),
start: match.startIndex,
end: match.endIndex + 1,
})
}
return matches
}
function findFirst(text: string): ProfanityMatch | undefined {
return findAll(text)[0]
}
function validate(text: string): ProfanityResult {
const matches = findAll(text)
const profanityCount = matches.filter((match) => match.kind === 'profanity').length
@@ -558,7 +305,6 @@ export function createProfanityValidator(
}
return {
sanitize: sanitizeProfanityText,
findFirst,
findAll,
validate,
@@ -1,66 +1,49 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { createProfanityValidator, sanitizeProfanityText, validateProfanity } from './index.ts'
import { createProfanityValidator, validateProfanity } from './index.ts'
test('sanitizes text with the configured single-character replacements', () => {
assert.equal(sanitizeProfanityText('4@3105789+$([{!|£€¥¢<'), 'aaeiostbgtsccciieeycc')
})
const blockedForms = [
['normal form', 'fuck'],
['capitalization', 'FUCK'],
['Unicode variants', 'fuck'],
['zero-width characters', 'f\u200Buck'],
['leetspeak', '$h!t'],
['period separators', 'f.u.c.k'],
['space separators', 'f u c k'],
['repeated letters', 'fuuuuuck'],
] as const
test('sanitizes paired characters before their single-character replacements', () => {
assert.equal(sanitizeProfanityText('()[]{}<>'), 'oooo')
})
for (const [form, input] of blockedForms) {
test(`blocks ${form}`, () => {
const result = validateProfanity(input)
test('strips accents, separators, emoji, and ASCII casing', () => {
assert.equal(sanitizeProfanityText('F Ü.C—K🙂'), 'fuck')
})
assert.equal(result.valid, false)
assert.equal(result.firstMatch?.rawText, input)
})
}
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', () => {
test('does not use term exceptions', () => {
const validator = createProfanityValidator({
patterns: {
bad: { kind: 'profanity', exceptions: ['notbadword'] },
bad: { kind: 'profanity' },
},
})
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')
assert.equal(validator.findFirst('not bad word')?.term, 'bad')
})
test('matches the first profanity while ignoring a later negative match', () => {
test('uses the first match when one configured term prefixes another', () => {
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: [] },
bad: { kind: 'profanity' },
badword: { kind: 'profanity' },
},
})
assert.equal(validator.findFirst('badword')?.term, 'bad')
})
test('does not match configured false-positive substrings', () => {
assert.equal(validateProfanity('Scunthorpe, Clitheroe, 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)
@@ -72,31 +55,31 @@ test('allows redacted profanity when the removed letters cannot reconstruct a te
assert.equal(validateProfanity('f.u.c.k').valid, false)
})
test('rejects slurs', () => {
test('classifies slurs separately from other profanity', () => {
const validator = createProfanityValidator({
patterns: {
forbidden: { kind: 'slur', exceptions: [] },
forbidden: { kind: 'slur' },
},
})
const result = validator.validate('FORBIDDEN')
assert.equal(validator.validate('forbidden').valid, false)
assert.equal(validator.validate('forbidden').slurCount, 1)
assert.equal(result.valid, false)
assert.equal(result.profanityCount, 0)
assert.equal(result.slurCount, 1)
})
test('counts matches from left to right without overlaps', () => {
test('returns non-overlapping matches and original input offsets', () => {
const validator = createProfanityValidator({
patterns: {
bad: { kind: 'profanity', exceptions: [] },
bad: { kind: 'profanity' },
},
})
assert.deepEqual(
validator
.findAll('bad-bad')
.map(({ sanitizedStart, sanitizedEnd }) => [sanitizedStart, sanitizedEnd]),
validator.findAll('b.a.d bad').map(({ start, end }) => [start, end]),
[
[0, 3],
[3, 6],
[0, 5],
[6, 9],
],
)
})
@@ -106,18 +89,9 @@ test('rejects invalid configuration', () => {
() =>
createProfanityValidator({
patterns: {
'not sanitized': { kind: 'profanity', exceptions: [] },
'not sanitized': { kind: 'profanity' },
},
}),
/term must already be sanitized/,
)
assert.throws(
() =>
createProfanityValidator({
patterns: {
bad: { kind: 'profanity', exceptions: ['innocent'] },
},
}),
/exception must contain bad/,
/term must contain only ASCII letters/,
)
})
@@ -40,9 +40,11 @@ export type ProjectTextValidationCode =
| 'description-missing-alt-text'
export interface ProjectTextValidationOptions {
maxProfanityCount?: number
nonStandardTextFailureThreshold?: number
}
export const DESCRIPTION_MAX_PROFANITY_COUNT = 1
export const DESCRIPTION_NON_STANDARD_TEXT_FAILURE_THRESHOLD = 0.05
export const MIN_DESCRIPTION_CHARS = 200
export const MAX_HEADER_LENGTH = 80
@@ -52,11 +54,11 @@ export const MIN_SUMMARY_CHARS = 30
const messages = defineMessages({
slur: {
id: 'project.text-validation.slur',
defaultMessage: 'Slurs are not allowed.',
defaultMessage: 'The detected slur “{value}” is not allowed.',
},
profanity: {
id: 'project.text-validation.profanity',
defaultMessage: 'Profanity is not allowed.',
defaultMessage: 'The detected profanity “{value}” is not allowed.',
},
nonStandardText: {
id: 'project.text-validation.non-standard-text',
@@ -275,11 +277,34 @@ export function validateProjectText(
if (!text) return []
const profanity = validateProfanity(text)
if (profanity.slurCount > 0) {
return [{ code: 'text-slur', severity: 'error', message: messages.slur }]
const slurMatch = profanity.matches.find((match) => match.kind === 'slur')
if (slurMatch) {
return [
{
code: 'text-slur',
severity: 'error',
message: messages.slur,
values: { value: slurMatch.rawText },
},
]
}
if (profanity.profanityCount > 0) {
return [{ code: 'text-profanity', severity: 'error', message: messages.profanity }]
const maxProfanityCount = options.maxProfanityCount ?? 0
if (!Number.isInteger(maxProfanityCount) || maxProfanityCount < 0) {
throw new Error('Maximum profanity count must be a non-negative integer')
}
const profanityMatch = profanity.matches.filter((match) => match.kind === 'profanity')[
maxProfanityCount
]
if (profanityMatch) {
return [
{
code: 'text-profanity',
severity: 'error',
message: messages.profanity,
values: { value: profanityMatch.rawText },
},
]
}
const nonStandardText = validateNonStandardText(text)
@@ -370,6 +395,7 @@ export function validateProjectDescription(
description: string | null | undefined,
): ProjectTextValidationResult[] {
const results = validateProjectText(description, {
maxProfanityCount: DESCRIPTION_MAX_PROFANITY_COUNT,
nonStandardTextFailureThreshold: DESCRIPTION_NON_STANDARD_TEXT_FAILURE_THRESHOLD,
})
if (results.length > 0) return results
@@ -55,10 +55,16 @@ test('extracts and deduplicates normalized links', () => {
test('validates shared project text', () => {
assert.deepEqual(validateProjectText('An ordinary project'), [])
assert.equal(
validateProjectText('This project is shit')[0]?.message.id,
'project.text-validation.profanity',
)
assert.deepEqual(validateProjectText('This project is SHIT')[0], {
code: 'text-profanity',
severity: 'error',
message: {
id: 'project.text-validation.profanity',
defaultMessage: 'The detected profanity “{value}” is not allowed.',
},
values: { value: 'SHIT' },
})
assert.deepEqual(validateProjectText('F.A.G')[0]?.values, { value: 'F.A.G' })
assert.equal(
validateProjectText('𝐅ancy project')[0]?.message.id,
'project.text-validation.non-standard-text',
@@ -135,6 +141,32 @@ test('allows sparse non-standard text in descriptions but rejects it at the thre
)
})
test('allows one profanity match in descriptions but rejects a second match or any slur', () => {
const description = 'A detailed project description '.repeat(10)
assert.equal(
validateProjectDescription(`${description} shit`).some(({ code }) => code === 'text-profanity'),
false,
)
assert.equal(
validateProjectDescription(`${description} f.u.c.k`).some(
({ code }) => code === 'text-profanity',
),
false,
)
assert.deepEqual(validateProjectDescription(`${description} shit FUCK`)[0], {
code: 'text-profanity',
severity: 'error',
message: {
id: 'project.text-validation.profanity',
defaultMessage: 'The detected profanity “{value}” is not allowed.',
},
values: { value: 'FUCK' },
})
assert.equal(validateProjectDescription(`${description} nigga`)[0]?.code, 'text-slur')
assert.equal(validateProjectDescription(`${description} nigger`)[0]?.code, 'text-slur')
})
test('validates required description content and returns simultaneous recommendations', () => {
assert.equal(validateProjectDescription(' ')[0]?.code, 'description-required')
@@ -33,7 +33,7 @@ test('validates project fields and gallery text', () => {
url: 'https://cdn.modrinth.com/gallery.png',
raw_url: 'https://cdn.modrinth.com/gallery.png',
featured: false,
name: 'This is shit',
name: 'This is $h!t',
description: '𝐁',
created: '2026-01-01T00:00:00Z',
ordering: 0,
@@ -84,6 +84,9 @@ test('validates project fields and gallery text', () => {
},
],
)
assert.deepEqual(result.failures.find(({ field }) => field === 'gallery-name')?.values, {
value: '$h!t',
})
})
test('reports whether a project has field validation failures', () => {
+9
View File
@@ -560,6 +560,9 @@ importers:
linkify-it:
specifier: ^5.0.0
version: 5.0.0
obscenity:
specifier: ^0.4.6
version: 0.4.6
vue:
specifier: ^3.5.13
version: 3.5.27(typescript@5.9.3)
@@ -8005,6 +8008,10 @@ packages:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
obscenity@0.4.6:
resolution: {integrity: sha512-pHk7kNN7j3L3zGhhGnwxjvXIGsPpLrcZl2r58fqWh/V/rH6b/dafscj2sMmAY+A/9/wPsocLmimgGk2DKeKsFQ==}
engines: {node: '>=18.0.0'}
obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
@@ -18670,6 +18677,8 @@ snapshots:
object-inspect@1.13.4: {}
obscenity@0.4.6: {}
obug@2.1.1: {}
ofetch@1.5.1: