feat: implement more project field validators

This commit is contained in:
tdgao
2026-08-25 23:18:31 -06:00
parent b9a2e95da9
commit 0d61960855
21 changed files with 601 additions and 77 deletions
+2
View File
@@ -15,12 +15,14 @@
"@modrinth/assets": "workspace:*",
"@modrinth/utils": "workspace:*",
"@modrinth/api-client": "workspace:*",
"linkify-it": "^5.0.0",
"vue": "^3.5.13"
},
"devDependencies": {
"@formatjs/cli": "^6.2.12",
"@modrinth/tooling-config": "workspace:*",
"@modrinth/ui": "workspace:*",
"@types/linkify-it": "^5.0.0",
"typescript": "^5.4.5"
}
}
+1
View File
@@ -22,3 +22,4 @@ export * from './utils'
export * from './validators/link-checks'
export * from './validators/non-standard-text'
export * from './validators/profanity'
export * from './validators/project-fields'
@@ -153,7 +153,7 @@
"defaultMessage": "The wiki is disabled on this repository."
},
"nags.link.invalid-url": {
"defaultMessage": "This is not a valid URL."
"defaultMessage": "There's an invalid URL in the description."
},
"nags.link.license.url-mismatch": {
"defaultMessage": "This link points to the {detected} license, but your project is set to {selected}."
@@ -343,5 +343,26 @@
},
"nags.visit-links-settings.title": {
"defaultMessage": "Visit links settings"
},
"project.text-validation.non-standard-text": {
"defaultMessage": "Non-standard text characters are not allowed."
},
"project.text-validation.profanity": {
"defaultMessage": "Profanity is not allowed."
},
"project.text-validation.slur": {
"defaultMessage": "Slurs are not allowed."
},
"project.text-validation.summary-link": {
"defaultMessage": "Links are not allowed in project summaries."
},
"project.text-validation.summary-matches-title": {
"defaultMessage": "A project summary cannot be the same as its title."
},
"project.text-validation.title-game-version": {
"defaultMessage": "Project titles cannot include the Minecraft version “{value}”."
},
"project.text-validation.title-loader": {
"defaultMessage": "Project titles cannot include the loader “{value}”."
}
}
@@ -17,6 +17,7 @@ function defineMessages<T extends Record<string, MessageDescriptor>>(descriptors
export interface LinkCheckContext {
url: string | undefined
field: string
generalContent?: boolean
[key: string]: unknown
}
@@ -226,7 +227,10 @@ async function matchNode(
(child) => !isAsyncMatcher(child.when) && !child.isFallback,
)
const asyncChildren = node.childNodes.filter(
(child) => isAsyncMatcher(child.when) && !child.isFallback,
(child) =>
isAsyncMatcher(child.when) &&
!child.isFallback &&
!(context.generalContent && hasFieldSpecificDescendant(child)),
)
const fallbackChildren = node.childNodes.filter((child) => child.isFallback)
let expectedChild: LinkCheckNode | undefined
@@ -272,7 +276,7 @@ const coreMessages = defineMessages({
//TODO: we should probably just let you not provide https but backend currently requires it
const invalidUrlMessage = defineMessage({
id: 'nags.link.invalid-url',
defaultMessage: 'This is not a valid URL.',
defaultMessage: "There's an invalid URL in the description.",
})
function validUrlPrefix(remaining: string): number | null {
@@ -314,6 +318,13 @@ function cacheKey(context: LinkCheckContext): string {
return JSON.stringify(context)
}
function hasFieldSpecificDescendant(node: LinkCheckNode): boolean {
return (
(node.forMatchers?.length ?? 0) > 0 ||
(node.childNodes?.some((child) => hasFieldSpecificDescendant(child)) ?? false)
)
}
async function checkLink(context: LinkCheckContext) {
const url = context.url
if (!url) return
@@ -327,6 +338,10 @@ async function checkLink(context: LinkCheckContext) {
const found = await matchNode(rootNode, normalizedUrl, context, true)
if (!found) {
if (context.generalContent && validUrlPrefix(normalizedUrl) !== null) {
cache.set(key, valid)
return
}
cache.delete(key)
return
}
@@ -336,6 +351,11 @@ async function checkLink(context: LinkCheckContext) {
const applies = isLeaf && matched.forMatchers?.some((matcher) => matchesField(matcher, context))
if (!applies) {
if (context.generalContent && hasFieldSpecificDescendant(matched)) {
cache.set(key, valid)
return
}
const build = matched.unrecognizedSeverity === 'warn' ? warn : error
if (matched.unrecognizedMessage && isLeaf) {
@@ -36,6 +36,37 @@ test('rejects a recognized link used in the wrong field', async () => {
assert.equal(getLinkCheckState(context)?.message?.id, 'nags.link.wrong-field')
})
test('allows structured link types in general content', async () => {
const context = {
field: 'description',
url: 'https://github.com/modrinth/code',
generalContent: true,
}
await checkLink(context)
assert.equal(getLinkCheckState(context)?.severity, 'valid')
})
test('allows unrecognized valid links but keeps global restrictions in general content', async () => {
const allowed = {
field: 'description',
url: 'https://docs.example.dev/project',
generalContent: true,
}
const blocked = {
field: 'description',
url: 'https://bit.ly/project',
generalContent: true,
}
await checkLink(allowed)
await checkLink(blocked)
assert.equal(getLinkCheckState(allowed)?.severity, 'valid')
assert.equal(getLinkCheckState(blocked)?.severity, 'error')
})
test('compares recognized license URLs with the selected license', async () => {
const matching = {
field: 'license',
@@ -29,6 +29,14 @@ export interface NonStandardTextOptions {
export const DEFAULT_MAX_COMBINING_MARKS_PER_CHARACTER = 2
export function getNonStandardTextRatio(text: string, result: NonStandardTextResult): number {
const characterCount = Array.from(text).length
if (characterCount === 0) return 0
const nonStandardCharacterCount = new Set(result.issues.map(({ index }) => index)).size
return nonStandardCharacterCount / characterCount
}
const FANCY_RANGES: ReadonlyArray<readonly [number, number]> = [
[0x02b0, 0x02ff],
[0x1d400, 0x1d7ff],
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { validateNonStandardText } from './index.ts'
import { getNonStandardTextRatio, validateNonStandardText } from './index.ts'
test('accepts ordinary multilingual text and punctuation', () => {
const result = validateNonStandardText(
@@ -119,3 +119,18 @@ test('reports multiple issue categories in source order', () => {
['fancy', 'invisible', 'control'],
)
})
test('calculates the ratio of non-standard Unicode characters', () => {
const belowFivePercent = '𝐀'.concat('a'.repeat(20))
const exactlyFivePercent = '𝐀'.concat('a'.repeat(19))
assert.equal(
getNonStandardTextRatio(belowFivePercent, validateNonStandardText(belowFivePercent)),
1 / 21,
)
assert.equal(
getNonStandardTextRatio(exactlyFivePercent, validateNonStandardText(exactlyFivePercent)),
0.05,
)
assert.equal(getNonStandardTextRatio('', validateNonStandardText('')), 0)
})
@@ -0,0 +1,195 @@
import LinkifyIt from 'linkify-it'
import { getNonStandardTextRatio, validateNonStandardText } from '../non-standard-text/index.ts'
import { validateProfanity } from '../profanity/index.ts'
export interface ProjectFieldMessageDescriptor {
id: string
defaultMessage?: string
description?: string
}
function defineMessages<T extends Record<string, ProjectFieldMessageDescriptor>>(descriptors: T): T {
return descriptors
}
export interface ProjectTextValidationResult {
severity: 'error'
message: ProjectFieldMessageDescriptor
values?: Record<string, unknown>
}
export interface ProjectTextValidationOptions {
nonStandardTextFailureThreshold?: number
}
export const DESCRIPTION_NON_STANDARD_TEXT_FAILURE_THRESHOLD = 0.05
const messages = defineMessages({
slur: {
id: 'project.text-validation.slur',
defaultMessage: 'Slurs are not allowed.',
},
profanity: {
id: 'project.text-validation.profanity',
defaultMessage: 'Profanity is not allowed.',
},
nonStandardText: {
id: 'project.text-validation.non-standard-text',
defaultMessage: 'Non-standard text characters are not allowed.',
},
titleGameVersion: {
id: 'project.text-validation.title-game-version',
defaultMessage: 'Project titles cannot include the Minecraft version “{value}”.',
},
titleLoader: {
id: 'project.text-validation.title-loader',
defaultMessage: 'Project titles cannot include the loader “{value}”.',
},
summaryLink: {
id: 'project.text-validation.summary-link',
defaultMessage: 'Links are not allowed in project summaries.',
},
summaryMatchesTitle: {
id: 'project.text-validation.summary-matches-title',
defaultMessage: 'A project summary cannot be the same as its title.',
},
})
const titleMetadataMessages = {
'game-version': messages.titleGameVersion,
loader: messages.titleLoader,
}
export type ProjectTitleMetadataKind = 'game-version' | 'loader'
export interface ProjectTitleMetadata {
gameVersions: readonly string[]
loaders: readonly string[]
}
export interface ProjectTitleMetadataMatch {
kind: ProjectTitleMetadataKind
value: string
}
const linkify = new LinkifyIt({
fuzzyEmail: false,
fuzzyIP: true,
fuzzyLink: true,
})
function normalizeForSearch(value: string) {
return value.normalize('NFC').toLowerCase()
}
export function findProjectTitleMetadata(
title: string,
metadata: ProjectTitleMetadata,
): ProjectTitleMetadataMatch | null {
const normalizedTitle = normalizeForSearch(title)
const groups: ReadonlyArray<readonly [ProjectTitleMetadataKind, readonly string[]]> = [
['game-version', metadata.gameVersions],
['loader', metadata.loaders],
]
for (const [kind, values] of groups) {
for (const value of values) {
const normalizedValue = normalizeForSearch(value.trim())
if (normalizedValue && normalizedTitle.includes(normalizedValue)) {
return { kind, value }
}
}
}
return null
}
export function normalizeProjectFieldText(value: string) {
return value.trim().normalize('NFC')
}
export function projectSummaryMatchesTitle(summary: string, title: string) {
const normalizedSummary = normalizeProjectFieldText(summary)
const normalizedTitle = normalizeProjectFieldText(title)
return normalizedSummary.length > 0 && normalizedSummary === normalizedTitle
}
export function extractProjectLinks(text: string) {
const matches = linkify.match(text) ?? []
return [...new Set(matches.map((match) => match.url))]
}
export function containsProjectLinkOrIp(text: string) {
return linkify.test(text)
}
export function validateProjectText(
text: string | null | undefined,
options: ProjectTextValidationOptions = {},
): ProjectTextValidationResult | null {
if (!text) return null
const profanity = validateProfanity(text)
if (profanity.slurCount > 0) {
return { severity: 'error', message: messages.slur }
}
if (profanity.profanityCount > 0) {
return { severity: 'error', message: messages.profanity }
}
const nonStandardText = validateNonStandardText(text)
const nonStandardTextFailureThreshold = options.nonStandardTextFailureThreshold ?? 0
if (
!nonStandardText.valid &&
getNonStandardTextRatio(text, nonStandardText) >= nonStandardTextFailureThreshold
) {
return { severity: 'error', message: messages.nonStandardText }
}
return null
}
export function validateProjectTitle(
text: string | null | undefined,
metadata: ProjectTitleMetadata,
): ProjectTextValidationResult | null {
const textValidation = validateProjectText(text)
if (textValidation || !text) return textValidation
const match = findProjectTitleMetadata(text, metadata)
if (!match) return null
return {
severity: 'error',
message: titleMetadataMessages[match.kind],
values: { value: match.value },
}
}
export function validateProjectSummary(
summary: string | null | undefined,
title: string | null | undefined,
): ProjectTextValidationResult | null {
const textValidation = validateProjectText(summary)
if (textValidation || !summary) return textValidation
if (containsProjectLinkOrIp(summary)) {
return { severity: 'error', message: messages.summaryLink }
}
if (title && projectSummaryMatchesTitle(summary, title)) {
return { severity: 'error', message: messages.summaryMatchesTitle }
}
return null
}
export function validateProjectDescription(
description: string | null | undefined,
): ProjectTextValidationResult | null {
return validateProjectText(description, {
nonStandardTextFailureThreshold: DESCRIPTION_NON_STANDARD_TEXT_FAILURE_THRESHOLD,
})
}
@@ -0,0 +1,101 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
containsProjectLinkOrIp,
extractProjectLinks,
findProjectTitleMetadata,
projectSummaryMatchesTitle,
validateProjectDescription,
validateProjectSummary,
validateProjectText,
validateProjectTitle,
} from './index.ts'
const metadata = {
gameVersions: ['1.21.1'],
loaders: ['fabric'],
}
test('finds game versions and loaders in project titles', () => {
assert.deepEqual(findProjectTitleMetadata('Tools for 1.21.1', metadata), {
kind: 'game-version',
value: '1.21.1',
})
assert.deepEqual(findProjectTitleMetadata('FABRIC Tools', metadata), {
kind: 'loader',
value: 'fabric',
})
assert.equal(findProjectTitleMetadata('Magical Tools', metadata), null)
assert.equal(findProjectTitleMetadata('Ordinary Tools', metadata), null)
})
test('compares summaries and titles after trimming and Unicode normalization', () => {
assert.equal(projectSummaryMatchesTitle(' Caf\u00e9 ', 'Cafe\u0301'), true)
assert.equal(projectSummaryMatchesTitle('Project summary', 'Project title'), false)
assert.equal(projectSummaryMatchesTitle('', ''), false)
})
test('detects links and IP addresses but not email addresses or game versions', () => {
assert.equal(containsProjectLinkOrIp('Visit https://modrinth.com'), true)
assert.equal(containsProjectLinkOrIp('Visit modrinth.com'), true)
assert.equal(containsProjectLinkOrIp('Join 127.0.0.1:25565'), true)
assert.equal(containsProjectLinkOrIp('Supports Minecraft 1.21.1'), false)
assert.equal(containsProjectLinkOrIp('Contact hello@example.com'), false)
})
test('extracts and deduplicates normalized links', () => {
assert.deepEqual(
extractProjectLinks(
'Visit [Modrinth](https://modrinth.com) and example.net twice: example.net',
),
['https://modrinth.com', 'http://example.net'],
)
})
test('validates shared project text', () => {
assert.equal(validateProjectText('An ordinary project'), null)
assert.equal(
validateProjectText('This project is shit')?.message.id,
'project.text-validation.profanity',
)
assert.equal(
validateProjectText('𝐅ancy project')?.message.id,
'project.text-validation.non-standard-text',
)
})
test('validates project title metadata', () => {
assert.deepEqual(validateProjectTitle('Fabric Tools', metadata), {
severity: 'error',
message: {
id: 'project.text-validation.title-loader',
defaultMessage: 'Project titles cannot include the loader “{value}”.',
},
values: { value: 'fabric' },
})
assert.equal(validateProjectTitle('Ordinary Tools', metadata), null)
})
test('validates project summaries', () => {
assert.equal(
validateProjectSummary('Visit modrinth.com', 'Project title')?.message.id,
'project.text-validation.summary-link',
)
assert.equal(
validateProjectSummary(' Caf\u00e9 ', 'Cafe\u0301')?.message.id,
'project.text-validation.summary-matches-title',
)
assert.equal(validateProjectSummary('Project summary', 'Project title'), null)
})
test('allows sparse non-standard text in descriptions but rejects it at the threshold', () => {
const belowFivePercent = '𝐀'.concat('a'.repeat(20))
const exactlyFivePercent = '𝐀'.concat('a'.repeat(19))
assert.equal(validateProjectDescription(belowFivePercent), null)
assert.equal(
validateProjectDescription(exactlyFivePercent)?.message.id,
'project.text-validation.non-standard-text',
)
})
+4 -1
View File
@@ -1,3 +1,6 @@
{
"extends": "@modrinth/tooling-config/typescript/vue.json"
"extends": "@modrinth/tooling-config/typescript/vue.json",
"compilerOptions": {
"allowImportingTsExtensions": true
}
}