mirror of
https://github.com/modrinth/code.git
synced 2026-08-30 11:36:05 +00:00
feat: polish profanity validation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 `A`.
|
||||
| '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('𝐇 Ⓗ ʰ ℌ h ff')
|
||||
const result = validateNonStandardText('𝐇 Ⓗ ʰ ℌ h 1 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', () => {
|
||||
|
||||
Generated
+9
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user