mirror of
https://github.com/modrinth/code.git
synced 2026-08-30 19:46:33 +00:00
467 lines
14 KiB
TypeScript
467 lines
14 KiB
TypeScript
import LinkifyIt from 'linkify-it'
|
|
|
|
import { getNonStandardTextRatio, validateNonStandardText } from '../non-standard-text/index.ts'
|
|
import { validateProfanity } from '../profanity/index.ts'
|
|
import { getBlockedProjectContentLink } from '../project-links/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 {
|
|
code: ProjectTextValidationCode
|
|
severity: 'warn' | 'error'
|
|
message: ProjectFieldMessageDescriptor
|
|
values?: Record<string, unknown>
|
|
}
|
|
|
|
export type ProjectTextValidationCode =
|
|
| 'text-slur'
|
|
| 'text-profanity'
|
|
| 'text-non-standard'
|
|
| 'text-banned-link'
|
|
| 'title-version-number'
|
|
| 'title-minecraft-branding'
|
|
| 'summary-link'
|
|
| 'summary-matches-title'
|
|
| 'summary-too-short'
|
|
| 'summary-special-formatting'
|
|
| 'description-required'
|
|
| 'description-too-short'
|
|
| 'description-long-headers'
|
|
| 'description-image-heavy'
|
|
| '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
|
|
export const MIN_CHARS_PER_IMAGE = 60
|
|
export const MIN_SUMMARY_CHARS = 30
|
|
|
|
const messages = defineMessages({
|
|
slur: {
|
|
id: 'project.text-validation.slur',
|
|
defaultMessage: 'The detected slur “{value}” is not allowed.',
|
|
},
|
|
profanity: {
|
|
id: 'project.text-validation.profanity',
|
|
defaultMessage: 'The detected profanity “{value}” is not allowed.',
|
|
},
|
|
descriptionProfanity: {
|
|
id: 'project.text-validation.description-profanity',
|
|
defaultMessage: 'Excessive profanity is not allowed. Detected: {values}',
|
|
},
|
|
nonStandardText: {
|
|
id: 'project.text-validation.non-standard-text',
|
|
defaultMessage: 'Non-standard text characters are not allowed.',
|
|
},
|
|
bannedLink: {
|
|
id: 'project.text-validation.banned-link',
|
|
defaultMessage: '“{url}” is not allowed in project summaries or descriptions.',
|
|
},
|
|
titleVersionNumber: {
|
|
id: 'project.text-validation.title-version-number',
|
|
defaultMessage: 'Names are not allowed to include version numbers.',
|
|
},
|
|
titleMinecraftBranding: {
|
|
id: 'nags.minecraft-title-clause.description',
|
|
defaultMessage:
|
|
'Projects must not use Minecraft\'s branding or include "Minecraft" as a significant part of the name.',
|
|
},
|
|
summaryMatchesTitle: {
|
|
id: 'project.text-validation.summary-matches-title',
|
|
defaultMessage: "A project summary cannot be the same as it's title.",
|
|
},
|
|
summaryTooShort: {
|
|
id: 'project.text-validation.summary-too-short',
|
|
defaultMessage:
|
|
'Your summary is {length, plural, one {# character} other {# characters}}. At least {minChars, plural, one {# character} other {# characters}} is recommended to create an informative and enticing summary.',
|
|
},
|
|
summarySpecialFormatting: {
|
|
id: 'nags.summary-special-formatting.description',
|
|
defaultMessage:
|
|
'Your summary should not contain formatting, line breaks, special characters, or links. The summary only displays plain text.',
|
|
},
|
|
descriptionRequired: {
|
|
id: 'nags.add-description.description',
|
|
defaultMessage:
|
|
"A description that clearly describes the project's purpose and function is required.",
|
|
},
|
|
descriptionTooShort: {
|
|
id: 'nags.description-too-short.description',
|
|
defaultMessage:
|
|
'Your description is {length, plural, one {# readable character} other {# readable characters}}. At least {minChars, plural, one {# character} other {# characters}} is recommended to create a clear and informative description.',
|
|
},
|
|
descriptionLongHeaders: {
|
|
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.',
|
|
},
|
|
descriptionImageHeavy: {
|
|
id: 'nags.image-heavy-description.description',
|
|
defaultMessage:
|
|
'Your Description should contain sufficient plain text or image alt-text, keeping it accessible to those using screen readers or with slow internet connections.',
|
|
},
|
|
descriptionMissingAltText: {
|
|
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.',
|
|
},
|
|
})
|
|
|
|
const linkify = new LinkifyIt({
|
|
fuzzyEmail: false,
|
|
fuzzyIP: true,
|
|
fuzzyLink: true,
|
|
})
|
|
|
|
function normalizeForSearch(value: string) {
|
|
return value.normalize('NFC').toLowerCase()
|
|
}
|
|
|
|
export function normalizeProjectFieldText(value: string) {
|
|
return value.trim().normalize('NFC')
|
|
}
|
|
|
|
export function projectSummaryMatchesTitle(summary: string, title: string) {
|
|
const normalizedSummary = normalizeProjectFieldText(summary).replace(/\s+/g, '')
|
|
const normalizedTitle = normalizeProjectFieldText(title).replace(/\s+/g, '')
|
|
|
|
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 containsExplicitHttpProjectLink(text: string) {
|
|
return (linkify.match(text) ?? []).some((match) => {
|
|
const schema = match.schema.toLowerCase()
|
|
return schema === 'http:' || schema === 'https:'
|
|
})
|
|
}
|
|
|
|
export function findBlockedProjectContentLink(text: string) {
|
|
for (const url of extractProjectLinks(text)) {
|
|
const blockedLink = getBlockedProjectContentLink(url)
|
|
if (blockedLink) return blockedLink
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
export function hasProjectSummaryFormatting(summary: string) {
|
|
return Boolean(
|
|
summary.match(/# .*/g) ||
|
|
summary.match(/---/g) ||
|
|
summary.match(/\n/g) ||
|
|
summary.match(/`.*`/g) ||
|
|
summary.match(/\*.*\*/g) ||
|
|
summary.match(/_.*_/g) ||
|
|
summary.match(/~~.*~~/g) ||
|
|
summary.match(/```/g) ||
|
|
summary.match(/> /g),
|
|
)
|
|
}
|
|
|
|
export function analyzeHeaderLength(markdown: string): {
|
|
hasLongHeaders: boolean
|
|
longHeaders: string[]
|
|
} {
|
|
if (!markdown) return { hasLongHeaders: false, longHeaders: [] }
|
|
|
|
const withoutCodeBlocks = markdown.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '')
|
|
const headers = [...withoutCodeBlocks.matchAll(/^(#{1,3})\s+(.+)$/gm)]
|
|
const longHeaders = headers
|
|
.map((match) => match[2].trim())
|
|
.filter((headerText) => {
|
|
const sentences = headerText.split(/[.!?]+/g).filter((sentence) => sentence.trim().length > 0)
|
|
return headerText.length > MAX_HEADER_LENGTH || sentences.length > 1
|
|
})
|
|
|
|
return {
|
|
hasLongHeaders: longHeaders.length > 0,
|
|
longHeaders,
|
|
}
|
|
}
|
|
|
|
export function countText(markdown: string): number {
|
|
if (!markdown) return 0
|
|
|
|
const withoutCode = markdown.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '')
|
|
const withoutImagesAndLinks = withoutCode
|
|
.replace(/!\[[^\]]*]\([^)]+\)/g, ' ')
|
|
.replace(/\[[^\]]*]\([^)]+\)/g, ' ')
|
|
const withoutHtml = withoutImagesAndLinks.replace(/<[^>]+>/g, ' ')
|
|
const withoutMarkdownSyntax = withoutHtml
|
|
.replace(/^(?:>[ \t]?)+/gm, '')
|
|
.replace(/^#{1,6}\s+/gm, ' ')
|
|
.replace(/[*_~`>-]/g, ' ')
|
|
.replace(/\|/g, ' ')
|
|
|
|
return withoutMarkdownSyntax.replace(/\s+/g, ' ').trim().length
|
|
}
|
|
|
|
export function analyzeImageContent(markdown: string): {
|
|
imageHeavy: boolean
|
|
hasEmptyAltText: boolean
|
|
} {
|
|
if (!markdown) return { imageHeavy: false, hasEmptyAltText: false }
|
|
|
|
const withoutCodeBlocks = markdown.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '')
|
|
const images = [...withoutCodeBlocks.matchAll(/!\[([^\]]*)\]\([^)]+\)/g)]
|
|
const htmlImages = [...withoutCodeBlocks.matchAll(/<img[^>]*>/gi)]
|
|
const totalImages = images.length + htmlImages.length
|
|
|
|
if (totalImages === 0) return { imageHeavy: false, hasEmptyAltText: false }
|
|
|
|
const textLength = countText(withoutCodeBlocks)
|
|
const recommendedTextLength = MIN_CHARS_PER_IMAGE * totalImages
|
|
const imageHeavy =
|
|
recommendedTextLength > MIN_DESCRIPTION_CHARS && textLength < recommendedTextLength
|
|
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 { imageHeavy, hasEmptyAltText }
|
|
}
|
|
|
|
export function validateProjectText(
|
|
text: string | null | undefined,
|
|
options: ProjectTextValidationOptions = {},
|
|
): ProjectTextValidationResult[] {
|
|
if (!text) return []
|
|
|
|
const profanity = validateProfanity(text)
|
|
const slurMatch = profanity.matches.find((match) => match.kind === 'slur')
|
|
if (slurMatch) {
|
|
return [
|
|
{
|
|
code: 'text-slur',
|
|
severity: 'error',
|
|
message: messages.slur,
|
|
values: { value: slurMatch.rawText },
|
|
},
|
|
]
|
|
}
|
|
|
|
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)
|
|
const nonStandardTextFailureThreshold = options.nonStandardTextFailureThreshold ?? 0
|
|
if (
|
|
!nonStandardText.valid &&
|
|
getNonStandardTextRatio(text, nonStandardText) >= nonStandardTextFailureThreshold
|
|
) {
|
|
return [{ code: 'text-non-standard', severity: 'error', message: messages.nonStandardText }]
|
|
}
|
|
|
|
return []
|
|
}
|
|
|
|
export function validateProjectTitle(
|
|
text: string | null | undefined,
|
|
): ProjectTextValidationResult[] {
|
|
const results = validateProjectText(text)
|
|
if (results.length > 0 || !text) return results
|
|
|
|
const normalizedTitle = normalizeForSearch(text)
|
|
const disallowedVersion = [...normalizedTitle.matchAll(/\d+(?:\.\d+)+/g)].find((match) => {
|
|
const textAfterVersion = normalizedTitle.slice((match.index ?? 0) + match[0].length)
|
|
return !/\b(?:port|fork)\b/.test(textAfterVersion)
|
|
})
|
|
if (disallowedVersion) {
|
|
results.push({
|
|
code: 'title-version-number',
|
|
severity: 'error',
|
|
message: messages.titleVersionNumber,
|
|
})
|
|
}
|
|
|
|
const wordsInTitle = normalizedTitle.split(/\s+/).filter(Boolean)
|
|
if (normalizedTitle.includes('minecraft') && wordsInTitle.length <= 3) {
|
|
results.push({
|
|
code: 'title-minecraft-branding',
|
|
severity: 'warn',
|
|
message: messages.titleMinecraftBranding,
|
|
})
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
export function validateProjectSummary(
|
|
summary: string | null | undefined,
|
|
title: string | null | undefined,
|
|
): ProjectTextValidationResult[] {
|
|
const results = validateProjectText(summary)
|
|
if (results.length > 0 || !summary) return results
|
|
|
|
const blockedLink = findBlockedProjectContentLink(summary)
|
|
if (blockedLink) {
|
|
return [
|
|
{
|
|
code: 'text-banned-link',
|
|
severity: 'error',
|
|
message: messages.bannedLink,
|
|
values: blockedLink,
|
|
},
|
|
]
|
|
}
|
|
|
|
const containsExplicitLink = containsExplicitHttpProjectLink(summary)
|
|
|
|
if (!containsExplicitLink && title && projectSummaryMatchesTitle(summary, title)) {
|
|
return [
|
|
{
|
|
code: 'summary-matches-title',
|
|
severity: 'error',
|
|
message: messages.summaryMatchesTitle,
|
|
},
|
|
]
|
|
}
|
|
|
|
const length = normalizeProjectFieldText(summary).length
|
|
if (!containsExplicitLink && length < MIN_SUMMARY_CHARS) {
|
|
results.push({
|
|
code: 'summary-too-short',
|
|
severity: 'warn',
|
|
message: messages.summaryTooShort,
|
|
values: { length, minChars: MIN_SUMMARY_CHARS },
|
|
})
|
|
}
|
|
|
|
if (hasProjectSummaryFormatting(summary) || containsExplicitLink) {
|
|
results.push({
|
|
code: containsExplicitLink ? 'summary-link' : 'summary-special-formatting',
|
|
severity: 'error',
|
|
message: messages.summarySpecialFormatting,
|
|
})
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
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[0]?.code === 'text-profanity') {
|
|
const detectedValues = validateProfanity(description ?? '')
|
|
.matches.filter((match) => match.kind === 'profanity')
|
|
.map((match) => `"${match.rawText}"`)
|
|
.join(', ')
|
|
|
|
return [
|
|
{
|
|
...results[0],
|
|
message: messages.descriptionProfanity,
|
|
values: { values: detectedValues },
|
|
},
|
|
]
|
|
}
|
|
if (results.length > 0) return results
|
|
|
|
const normalizedDescription = normalizeProjectFieldText(description ?? '')
|
|
if (!normalizedDescription) {
|
|
return [
|
|
{
|
|
code: 'description-required',
|
|
severity: 'error',
|
|
message: messages.descriptionRequired,
|
|
},
|
|
]
|
|
}
|
|
|
|
const blockedLink = findBlockedProjectContentLink(normalizedDescription)
|
|
if (blockedLink) {
|
|
return [
|
|
{
|
|
code: 'text-banned-link',
|
|
severity: 'error',
|
|
message: messages.bannedLink,
|
|
values: blockedLink,
|
|
},
|
|
]
|
|
}
|
|
|
|
const readableLength = countText(normalizedDescription)
|
|
if (readableLength < MIN_DESCRIPTION_CHARS) {
|
|
results.push({
|
|
code: 'description-too-short',
|
|
severity: 'warn',
|
|
message: messages.descriptionTooShort,
|
|
values: { length: readableLength, minChars: MIN_DESCRIPTION_CHARS },
|
|
})
|
|
}
|
|
|
|
const { hasLongHeaders, longHeaders } = analyzeHeaderLength(normalizedDescription)
|
|
if (hasLongHeaders) {
|
|
results.push({
|
|
code: 'description-long-headers',
|
|
severity: 'warn',
|
|
message: messages.descriptionLongHeaders,
|
|
values: { count: longHeaders.length },
|
|
})
|
|
}
|
|
|
|
const { imageHeavy, hasEmptyAltText } = analyzeImageContent(normalizedDescription)
|
|
if (imageHeavy) {
|
|
results.push({
|
|
code: 'description-image-heavy',
|
|
severity: 'warn',
|
|
message: messages.descriptionImageHeavy,
|
|
})
|
|
}
|
|
if (hasEmptyAltText) {
|
|
results.push({
|
|
code: 'description-missing-alt-text',
|
|
severity: 'warn',
|
|
message: messages.descriptionMissingAltText,
|
|
})
|
|
}
|
|
|
|
return results
|
|
}
|