import { defineMessages } from '@modrinth/ui/i18n'
import { renderString } from '@modrinth/utils/parse.ts'
import LinkifyIt from 'linkify-it'
import { parse } from 'node-html-parser'
import tlds from 'tlds' with { type: 'json' }
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { URL_SHORTENERS } from '../../validators/links/block-list.ts'
import {
getLinkHostname,
hostnameMatchesDomain,
isIpAddress,
} from '../../validators/links/syntax-checks.ts'
import { validateSpam } from '../../validators/spam/index.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import {
evaluateNonStandardText,
evaluateProfanity,
evaluateSlur,
normalizeProjectFieldText,
} from '../text.ts'
import { toFieldMessages } from '../to-field-messages.ts'
import { toNags } from '../to-nags.ts'
import type { FieldValidationMessage, ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
fixDescription: {
id: 'nags.invalid-project-description.title',
defaultMessage: 'Fix the project description',
},
addDescription: {
id: 'nags.add-description.title',
defaultMessage: 'Add a description',
},
expandDescription: {
id: 'nags.description-too-short.title',
defaultMessage: 'Expand the description',
},
removeSpam: {
id: 'nags.project-description-spam.title',
defaultMessage: 'Remove spam from the description',
},
shortenHeaders: {
id: 'nags.long-headers.title',
defaultMessage: 'Shorten headers',
},
addImageAltText: {
id: 'nags.missing-alt-text.title',
defaultMessage: 'Add image alt text',
},
editDescription: {
id: 'nags.edit-description.title',
defaultMessage: 'Edit description',
},
slur: {
id: 'nags.project-description-slur.description',
defaultMessage: 'Your project cannot contain any slurs. Detected: “{value}”.',
},
profanity: {
id: 'nags.project-description-profanity.description',
defaultMessage: 'Your project cannot contain excessive profanity. Detected: “{value}”.',
},
nonStandardText: {
id: 'nags.project-description-non-standard-text.description',
defaultMessage: 'Non-standard text characters, such as “₮ɆӾ₮”, are not allowed.',
},
bannedLink: {
id: 'nags.project-description-banned-link.description',
defaultMessage: '“{fullUrl}” is not allowed in project descriptions.',
},
required: {
id: 'nags.add-description.description',
defaultMessage:
"A description that clearly describes the project's purpose and function is required.",
},
tooShort: {
id: 'nags.description-too-short.description',
defaultMessage:
"Your description is too brief. Add more to clearly describe the project's purpose and function.",
},
spam: {
id: 'nags.project-description-spam.description',
defaultMessage:
'Repeated characters, words, or phrases cannot be used to pad a project description.',
},
longHeaders: {
id: 'nags.long-headers.description',
defaultMessage:
'{count, plural, one {# header} other {# headers}} in your description {count, plural, one {is} other {are}} too long. Headers should be concise and act as section titles, not full sentences.',
},
missingAltText: {
id: 'nags.missing-alt-text.description',
defaultMessage:
'Some of your images are missing alt text, which is important for accessibility, especially for visually impaired users.',
},
})
export const DESCRIPTION_MAX_PROFANITY_COUNT = 2
export const DESCRIPTION_NON_STANDARD_TEXT_FAILURE_THRESHOLD = 0.05
export const MIN_DESCRIPTION_CHARS = 125
export const MAX_HEADER_LENGTH = 80
export const BANNED_DESCRIPTION_LINK_DOMAINS = [...URL_SHORTENERS] as const
const descriptionLinkify = new LinkifyIt({
fuzzyEmail: false,
fuzzyIP: true,
fuzzyLink: true,
}).tlds(tlds)
const headerCharacterSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
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 extractRenderedHeaders(markdown: string): string[] {
if (!markdown) return []
const renderedDescription = parse(renderString(markdown))
return renderedDescription
.querySelectorAll('h1, h2, h3')
.map((header) => header.textContent.replace(/\s+/g, ' ').trim())
}
function countHeaderCharacters(header: string): number {
return [...headerCharacterSegmenter.segment(header)].length
}
export function analyzeHeaderLength(markdown: string): {
hasLongHeaders: boolean
longHeaders: string[]
} {
const longHeaders = extractRenderedHeaders(markdown).filter(
(header) => countHeaderCharacters(header) > MAX_HEADER_LENGTH,
)
return { hasLongHeaders: longHeaders.length > 0, longHeaders }
}
export function extractDescriptionText(markdown: string): string {
if (!markdown) return ''
const withoutCode = markdown.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '')
const withoutImagesAndLinks = withoutCode
.replace(/!\[([^\]]*)]\([^)]+\)/g, '$1')
.replace(/\[[^\]]*]\([^)]+\)/g, ' ')
const withHtmlImageAltText = withoutImagesAndLinks.replace(/
]*>/gi, (image) => {
const altMatch = image.match(/alt\s*=\s*(?:"([^"]*)"|'([^']*)')/i)
return altMatch?.[1] ?? altMatch?.[2] ?? ' '
})
const withoutHtml = withHtmlImageAltText.replace(/<[^>]+>/g, ' ')
const withoutMarkdownSyntax = withoutHtml
.replace(/^(?:>[ \t]?)+/gm, '')
.replace(/^#{1,6}\s+/gm, ' ')
.replace(/[*_~`>-]/g, ' ')
.replace(/\|/g, ' ')
return withoutMarkdownSyntax.replace(/\s+/g, ' ').trim()
}
export function countText(markdown: string): number {
return extractDescriptionText(markdown).length
}
export function analyzeImageContent(markdown: string): {
hasEmptyAltText: boolean
} {
if (!markdown) return { hasEmptyAltText: false }
const withoutCodeBlocks = markdown.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '')
const images = [...withoutCodeBlocks.matchAll(/!\[([^\]]*)\]\([^)]+\)/g)]
const htmlImages = [...withoutCodeBlocks.matchAll(/
]*>/gi)]
const hasEmptyAltText =
images.some((match) => !match[1]?.trim()) ||
htmlImages.some((match) => {
const altMatch = match[0].match(/alt\s*=\s*["']([^"']*)["']/i)
return !altMatch || !altMatch[1]?.trim()
})
return { hasEmptyAltText }
}
type DescriptionInput = string | null | undefined
const commonNagPresentation = {
destination: 'description',
linkTitle: messages.editDescription,
} as const
export const projectDescriptionValidationRules = {
'project-description-slur': {
severity: 'error',
evaluate: (description) => evaluateSlur(description ?? ''),
presentation: {
message: messages.slur,
nag: { title: messages.fixDescription, ...commonNagPresentation },
},
},
'project-description-profanity': {
severity: 'error',
evaluate: (description) =>
evaluateProfanity(description ?? '', DESCRIPTION_MAX_PROFANITY_COUNT),
presentation: {
message: messages.profanity,
nag: { title: messages.fixDescription, ...commonNagPresentation },
},
},
'project-description-non-standard-text': {
severity: 'error',
evaluate: (description) =>
evaluateNonStandardText(description ?? '', DESCRIPTION_NON_STANDARD_TEXT_FAILURE_THRESHOLD),
presentation: {
message: messages.nonStandardText,
nag: { title: messages.fixDescription, ...commonNagPresentation },
},
},
'add-description': {
severity: 'error',
evaluate: (description) => ({
valid: normalizeProjectFieldText(description ?? '').length > 0,
}),
presentation: {
message: messages.required,
nag: { title: messages.addDescription, ...commonNagPresentation },
},
},
'description-too-short': {
severity: 'error',
evaluate: (description) => {
const normalized = normalizeProjectFieldText(description ?? '')
if (!normalized) return { valid: true }
const length = countText(normalized)
return length >= MIN_DESCRIPTION_CHARS
? { valid: true }
: { valid: false, values: { length, minChars: MIN_DESCRIPTION_CHARS } }
},
presentation: {
message: messages.tooShort,
nag: { title: messages.expandDescription, ...commonNagPresentation },
},
},
'project-description-spam': {
severity: 'error',
evaluate: (description) => ({
valid: validateSpam(extractDescriptionText(description ?? '')).valid,
}),
presentation: {
message: messages.spam,
nag: { title: messages.removeSpam, ...commonNagPresentation },
},
},
'project-description-banned-link': {
severity: 'error',
evaluate: (description) => {
const bannedLink = findBannedDescriptionLink(description ?? '')
if (bannedLink) {
return { valid: false, values: { fullUrl: bannedLink } }
} else {
return { valid: true }
}
},
presentation: {
message: messages.bannedLink,
nag: { title: messages.fixDescription, ...commonNagPresentation },
},
},
'long-headers': {
severity: 'error',
evaluate: (description) => {
const { longHeaders } = analyzeHeaderLength(description ?? '')
if (longHeaders.length > 0) {
return { valid: false, values: { count: longHeaders.length } }
} else {
return { valid: true }
}
},
presentation: {
message: messages.longHeaders,
nag: { title: messages.shortenHeaders, ...commonNagPresentation },
},
},
'missing-alt-text': {
severity: 'warning',
evaluate: (description) => ({
valid: !analyzeImageContent(description ?? '').hasEmptyAltText,
}),
presentation: {
message: messages.missingAltText,
nag: { title: messages.addImageAltText, ...commonNagPresentation },
},
},
} satisfies ValidationRuleSet
export function validateProjectDescription(
description: DescriptionInput,
): FieldValidationMessage[] {
return toFieldMessages(evaluateRules(description, projectDescriptionValidationRules))
}
export function getDescriptionNags(context: Pick): Nag[] {
return toNags(evaluateRules(context.projectV3.description, projectDescriptionValidationRules))
}