feat: clean up link detection in summary and description

This commit is contained in:
tdgao
2026-08-28 12:10:19 -06:00
parent 18c1f31514
commit d7442e8760
15 changed files with 212 additions and 162 deletions
@@ -1,7 +1,14 @@
import { defineMessages } from '@modrinth/ui/i18n'
import LinkifyIt from 'linkify-it'
import tlds from 'tlds' with { type: 'json' }
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { findBlockedProjectContentLink } from '../../validators/links/detection.ts'
import { URL_SHORTENERS } from '../../validators/links/block-list.ts'
import {
getLinkHostname,
hostnameMatchesDomain,
isIpAddress,
} from '../../validators/links/syntax-checks.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import {
evaluateNonStandardText,
@@ -85,11 +92,46 @@ const messages = defineMessages({
},
})
export const DESCRIPTION_MAX_PROFANITY_COUNT = 1
export const DESCRIPTION_MAX_PROFANITY_COUNT = 2
export const DESCRIPTION_NON_STANDARD_TEXT_FAILURE_THRESHOLD = 0.05
export const MIN_DESCRIPTION_CHARS = 200
export const MAX_HEADER_LENGTH = 80
export const MIN_CHARS_PER_IMAGE = 60
export const BANNED_DESCRIPTION_LINK_DOMAINS = [...URL_SHORTENERS] as const
const descriptionLinkify = new LinkifyIt({
fuzzyEmail: false,
fuzzyIP: true,
fuzzyLink: true,
}).tlds(tlds)
export function extractDescriptionLinks(description: string): string[] {
const matches = descriptionLinkify.match(description) ?? []
return [
...new Set(
matches
.map((match) => match.url)
.filter((url) => {
const hostname = getLinkHostname(url)
return hostname === null || !isIpAddress(hostname)
}),
),
]
}
export function findBannedDescriptionLink(description: string): string | null {
for (const url of extractDescriptionLinks(description)) {
const hostname = getLinkHostname(url)
if (
hostname &&
BANNED_DESCRIPTION_LINK_DOMAINS.some((domain) => hostnameMatchesDomain(hostname, domain))
) {
return url
}
}
return null
}
export function analyzeHeaderLength(markdown: string): {
hasLongHeaders: boolean
@@ -199,8 +241,12 @@ export const projectDescriptionValidationRules = {
'project-description-banned-link': {
severity: 'error',
evaluate: (description) => {
const blockedLink = findBlockedProjectContentLink(description ?? '')
return blockedLink ? { valid: false, values: { fullUrl: blockedLink.url } } : { valid: true }
const bannedLink = findBannedDescriptionLink(description ?? '')
if (bannedLink) {
return { valid: false, values: { fullUrl: bannedLink } }
} else {
return { valid: true }
}
},
presentation: {
message: messages.bannedLink,
@@ -213,9 +259,11 @@ export const projectDescriptionValidationRules = {
const normalized = normalizeProjectFieldText(description ?? '')
if (!normalized) return { valid: true }
const length = countText(normalized)
return length < MIN_DESCRIPTION_CHARS
? { valid: false, values: { length, minChars: MIN_DESCRIPTION_CHARS } }
: { valid: true }
if (length < MIN_DESCRIPTION_CHARS) {
return { valid: false, values: { length, minChars: MIN_DESCRIPTION_CHARS } }
} else {
return { valid: true }
}
},
presentation: {
message: messages.tooShort,
@@ -226,9 +274,11 @@ export const projectDescriptionValidationRules = {
severity: 'warning',
evaluate: (description) => {
const { longHeaders } = analyzeHeaderLength(description ?? '')
return longHeaders.length > 0
? { valid: false, values: { count: longHeaders.length } }
: { valid: true }
if (longHeaders.length > 0) {
return { valid: false, values: { count: longHeaders.length } }
} else {
return { valid: true }
}
},
presentation: {
message: messages.longHeaders,
@@ -1,10 +1,8 @@
import { defineMessages } from '@modrinth/ui/i18n'
import LinkifyIt from 'linkify-it'
import tlds from 'tlds' with { type: 'json' }
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import {
containsExplicitHttpProjectLink,
findBlockedProjectContentLink,
} from '../../validators/links/detection.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import {
evaluateNonStandardText,
@@ -49,10 +47,6 @@ const messages = defineMessages({
id: 'nags.project-summary-non-standard-text.description',
defaultMessage: 'Non-standard text characters, such as “₮ɆӾ₮”, are not allowed.',
},
bannedLink: {
id: 'nags.project-summary-banned-link.description',
defaultMessage: '“{fullUrl}” is not allowed in project summaries.',
},
matchesName: {
id: 'project.text-validation.summary-matches-title',
defaultMessage: "A project summary cannot be the same as it's title.",
@@ -76,6 +70,16 @@ export interface ProjectSummaryValidationInput {
name: string | null | undefined
}
const summaryLinkify = new LinkifyIt({
fuzzyEmail: false,
fuzzyIP: true,
fuzzyLink: true,
}).tlds(tlds)
function containsProjectSummaryLinkOrIp(summary: string): boolean {
return summaryLinkify.test(summary)
}
export function projectSummaryMatchesName(summary: string, name: string) {
const normalizedSummary = normalizeProjectFieldText(summary).replace(/\s+/g, '')
const normalizedName = normalizeProjectFieldText(name).replace(/\s+/g, '')
@@ -127,23 +131,12 @@ export const projectSummaryValidationRules = {
nag: { title: messages.fixSummary, ...commonNagPresentation },
},
},
'project-summary-banned-link': {
severity: 'error',
evaluate: ({ summary }) => {
const blockedLink = findBlockedProjectContentLink(summary ?? '')
return blockedLink ? { valid: false, values: { fullUrl: blockedLink.url } } : { valid: true }
},
presentation: {
message: messages.bannedLink,
nag: { title: messages.fixSummary, ...commonNagPresentation },
},
},
'project-summary-matches-title': {
severity: 'error',
evaluate: ({ summary, name }) => ({
valid:
!summary ||
containsExplicitHttpProjectLink(summary) ||
containsProjectSummaryLinkOrIp(summary) ||
!name ||
!projectSummaryMatchesName(summary, name),
}),
@@ -155,7 +148,7 @@ export const projectSummaryValidationRules = {
'summary-too-short': {
severity: 'warning',
evaluate: ({ summary }) => {
if (!summary || containsExplicitHttpProjectLink(summary)) return { valid: true }
if (!summary || containsProjectSummaryLinkOrIp(summary)) return { valid: true }
const length = normalizeProjectFieldText(summary).length
return length < MIN_SUMMARY_CHARS
? { valid: false, values: { length, minChars: MIN_SUMMARY_CHARS } }
@@ -171,7 +164,7 @@ export const projectSummaryValidationRules = {
evaluate: ({ summary }) => ({
valid:
!summary ||
(!hasProjectSummaryFormatting(summary) && !containsExplicitHttpProjectLink(summary)),
(!hasProjectSummaryFormatting(summary) && !containsProjectSummaryLinkOrIp(summary)),
}),
presentation: {
message: messages.specialFormatting,