refactor: centralize project validation and nags into validation rules

This commit is contained in:
tdgao
2026-08-27 22:03:04 -06:00
parent 1f3f37c86c
commit 18c1f31514
83 changed files with 2752 additions and 3428 deletions
@@ -0,0 +1,268 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { findBlockedProjectContentLink } from '../../validators/links/detection.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',
},
shortenHeaders: {
id: 'nags.long-headers.title',
defaultMessage: 'Shorten headers',
},
ensureAccessibility: {
id: 'nags.image-heavy-description.title',
defaultMessage: 'Ensure accessibility',
},
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 {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.',
},
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.',
},
imageHeavy: {
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.',
},
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 = 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 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 }
}
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 },
},
},
'project-description-banned-link': {
severity: 'error',
evaluate: (description) => {
const blockedLink = findBlockedProjectContentLink(description ?? '')
return blockedLink ? { valid: false, values: { fullUrl: blockedLink.url } } : { valid: true }
},
presentation: {
message: messages.bannedLink,
nag: { title: messages.fixDescription, ...commonNagPresentation },
},
},
'description-too-short': {
severity: 'warning',
evaluate: (description) => {
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 }
},
presentation: {
message: messages.tooShort,
nag: { title: messages.expandDescription, ...commonNagPresentation },
},
},
'long-headers': {
severity: 'warning',
evaluate: (description) => {
const { longHeaders } = analyzeHeaderLength(description ?? '')
return longHeaders.length > 0
? { valid: false, values: { count: longHeaders.length } }
: { valid: true }
},
presentation: {
message: messages.longHeaders,
nag: { title: messages.shortenHeaders, ...commonNagPresentation },
},
},
'image-heavy-description': {
severity: 'warning',
evaluate: (description) => ({
valid: !analyzeImageContent(description ?? '').imageHeavy,
}),
presentation: {
message: messages.imageHeavy,
nag: { title: messages.ensureAccessibility, ...commonNagPresentation },
},
},
'missing-alt-text': {
severity: 'warning',
evaluate: (description) => ({
valid: !analyzeImageContent(description ?? '').hasEmptyAltText,
}),
presentation: {
message: messages.missingAltText,
nag: { title: messages.addImageAltText, ...commonNagPresentation },
},
},
} satisfies ValidationRuleSet<DescriptionInput>
export function validateProjectDescription(
description: DescriptionInput,
): FieldValidationMessage[] {
return toFieldMessages(evaluateRules(description, projectDescriptionValidationRules))
}
export function getDescriptionNags(context: Pick<ProjectValidationContext, 'projectV3'>): Nag[] {
return toNags(evaluateRules(context.projectV3.description, projectDescriptionValidationRules))
}
@@ -0,0 +1,43 @@
import { formatProjectTypeSentence } from '@modrinth/ui'
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { toNags } from '../to-nags.ts'
import type { ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
title: {
id: 'nags.check-disclosures.title',
defaultMessage: 'Check content disclosures',
},
description: {
id: 'nags.check-disclosures.description',
defaultMessage:
'Make sure users are aware of any important details by filling in content disclosures that apply to your {type}.',
},
})
export const projectDisclosureValidationRules = {
'check-disclosures': {
severity: 'suggestion',
evaluate: (context) => ({
valid: false,
values: { projectType: context.project.project_type },
}),
presentation: {
message: messages.description,
nag: {
title: messages.title,
destination: 'disclosures',
formatValues: (values, formatMessage) => ({
type: formatProjectTypeSentence(formatMessage, String(values.projectType)),
}),
},
},
},
} satisfies ValidationRuleSet<ProjectValidationContext>
export function getDisclosureNags(context: ProjectValidationContext): Nag[] {
return toNags(evaluateRules(context, projectDisclosureValidationRules))
}
@@ -0,0 +1,170 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { evaluateNonStandardText, evaluateProfanity, evaluateSlur } 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({
uploadImage: {
id: 'nags.upload-gallery-image.title',
defaultMessage: 'Upload a gallery image',
},
uploadResourcePackImage: {
id: 'nags.upload-gallery-image.description-resourcepack',
defaultMessage:
'At least one gallery image is required to showcase the content of your resource pack, except for audio or localization packs. If this describes your pack, please select the appropriate tag.',
},
uploadShaderImages: {
id: 'nags.upload-gallery-image.description-shader',
defaultMessage:
'At least three gallery images are required to showcase the content of your shader in a variety of situations and conditions.',
},
uploadImageDescription: {
id: 'nags.upload-gallery-image.description',
defaultMessage:
'At least one gallery image is required to showcase the content of your {type}.',
},
featureImage: {
id: 'nags.feature-gallery-image.title',
defaultMessage: 'Feature a gallery image',
},
featureImageDescription: {
id: 'nags.feature-gallery-image.description',
defaultMessage:
'The featured gallery image is often how your project makes its first impression.',
},
fixText: {
id: 'nags.invalid-gallery-text.title',
defaultMessage: 'Fix gallery text',
},
editGallery: {
id: 'nags.edit-gallery.title',
defaultMessage: 'Edit gallery',
},
slur: {
id: 'nags.gallery-text-slur.description',
defaultMessage: 'Your gallery cannot contain any slurs. Detected: “{value}”.',
},
profanity: {
id: 'nags.gallery-text-profanity.description',
defaultMessage: 'Your gallery cannot contain excessive profanity. Detected: “{value}”.',
},
nonStandardText: {
id: 'nags.gallery-text-non-standard.description',
defaultMessage: 'Non-standard text characters, such as “₮ɆӾ₮”, are not allowed.',
},
})
type GalleryTextInput = string | null | undefined
export const projectGalleryTextValidationRules = {
'gallery-text-slur': {
severity: 'error',
evaluate: (text) => evaluateSlur(text ?? ''),
presentation: {
message: messages.slur,
nag: {
title: messages.fixText,
destination: 'gallery',
linkTitle: messages.editGallery,
},
},
},
'gallery-text-profanity': {
severity: 'error',
evaluate: (text) => evaluateProfanity(text ?? ''),
presentation: {
message: messages.profanity,
nag: {
title: messages.fixText,
destination: 'gallery',
linkTitle: messages.editGallery,
},
},
},
'gallery-text-non-standard': {
severity: 'error',
evaluate: (text) => evaluateNonStandardText(text ?? ''),
presentation: {
message: messages.nonStandardText,
nag: {
title: messages.fixText,
destination: 'gallery',
linkTitle: messages.editGallery,
},
},
},
} satisfies ValidationRuleSet<GalleryTextInput>
export const projectGalleryValidationRules = {
'upload-gallery-image': {
severity: 'error',
evaluate: (context) => {
const isShader = context.projectV3.project_types.includes('shader')
if (isShader && context.project.gallery && context.project.gallery.length < 3) {
return { valid: false, message: messages.uploadShaderImages }
}
const isResourcePack = context.projectV3.project_types.includes('resourcepack')
const categories = context.project.categories.concat(
context.project.additional_categories ?? [],
)
if (
isResourcePack &&
context.project.gallery &&
context.project.gallery.length === 0 &&
!categories.includes('audio') &&
!categories.includes('locale')
) {
return { valid: false, message: messages.uploadResourcePackImage }
}
return { valid: true }
},
presentation: {
message: messages.uploadImageDescription,
nag: { title: messages.uploadImage, destination: 'gallery' },
},
},
'feature-gallery-image': {
severity: 'suggestion',
evaluate: (context) => ({
valid:
Boolean(context.projectV3.minecraft_server) ||
Boolean(context.project.gallery?.find((image) => image.featured)),
}),
presentation: {
message: messages.featureImageDescription,
nag: { title: messages.featureImage, destination: 'gallery' },
},
},
} satisfies ValidationRuleSet<ProjectValidationContext>
export function validateProjectGalleryName(name: GalleryTextInput): FieldValidationMessage[] {
return toFieldMessages(evaluateRules(name, projectGalleryTextValidationRules))
}
export function validateProjectGalleryDescription(
description: GalleryTextInput,
): FieldValidationMessage[] {
return toFieldMessages(evaluateRules(description, projectGalleryTextValidationRules))
}
export function getGalleryNags(context: ProjectValidationContext): Nag[] {
const galleryNags = toNags(evaluateRules(context, projectGalleryValidationRules))
const textNags = context.projectV3.gallery.flatMap((item, index) => {
const nameNags = toNags(evaluateRules(item.name, projectGalleryTextValidationRules)).map(
(nag) => ({ ...nag, id: `${nag.id}-${index}-name` }),
)
const descriptionNags = toNags(
evaluateRules(item.description, projectGalleryTextValidationRules),
).map((nag) => ({ ...nag, id: `${nag.id}-${index}-description` }))
return [...nameNags, ...descriptionNags]
})
return [...galleryNags, ...textNags]
}
@@ -0,0 +1,33 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { toNags } from '../to-nags.ts'
import type { ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
title: {
id: 'nags.add-icon.title',
defaultMessage: 'Add an icon',
},
description: {
id: 'nags.add-icon.description',
defaultMessage:
'Adding a unique, relevant, and engaging icon makes your project identifiable and helps it stand out.',
},
})
export const projectIconValidationRules = {
'add-icon': {
severity: 'suggestion',
evaluate: (context) => ({ valid: Boolean(context.project.icon_url) }),
presentation: {
message: messages.description,
nag: { title: messages.title, destination: 'general' },
},
},
} satisfies ValidationRuleSet<ProjectValidationContext>
export function getIconNags(context: ProjectValidationContext): Nag[] {
return toNags(evaluateRules(context, projectIconValidationRules))
}
@@ -0,0 +1,122 @@
import { formatProjectTypeSentence } from '@modrinth/ui'
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { getLinkHostname, isInappropriateLicenseLink } from '../../validators/links/index.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { toNags } from '../to-nags.ts'
import type { ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
selectLicense: {
id: 'nags.select-license.title',
defaultMessage: 'Select a license',
},
selectLicenseDescription: {
id: 'nags.select-license.description',
defaultMessage: 'Select the license your {type} is distributed under.',
},
addDetails: {
id: 'nags.add-license-details.title',
defaultMessage: 'Add license details',
},
addDetailsDescription: {
id: 'nags.add-license-details.description',
defaultMessage: 'Add a valid URL and name or SPDX identifier for your custom license.',
},
invalidUrl: {
id: 'nags.invalid-license-url.title',
defaultMessage: 'Add a valid license link',
},
invalidUrlDefault: {
id: 'nags.invalid-license-url.description.default',
defaultMessage: 'License URL is invalid.',
},
invalidUrlDomain: {
id: 'nags.invalid-license-url.description.domain',
defaultMessage:
'Your license URL points to {domain}, which is not appropriate for license information. License URLs should link directly to your license file, not social media, gaming platforms, etc.',
},
invalidUrlMalformed: {
id: 'nags.invalid-license-url.description.malformed',
defaultMessage:
'Your license URL appears to be malformed. Please provide a valid URL to your license text.',
},
editLicense: {
id: 'nags.edit-license.title',
defaultMessage: 'Edit license',
},
})
export const projectLicenseValidationRules = {
'select-license': {
severity: 'error',
evaluate: (context) => {
const licenseId = context.project.license.id
const unknown =
licenseId === 'LicenseRef-Unknown' ||
licenseId === 'NOASSERTION' ||
licenseId === 'LicenseRef-NOASSERTION'
return unknown && !context.projectV3.minecraft_server
? { valid: false, values: { projectType: context.project.project_type } }
: { valid: true }
},
presentation: {
message: messages.selectLicenseDescription,
nag: {
title: messages.selectLicense,
destination: 'license',
formatValues: (values, formatMessage) => ({
type: formatProjectTypeSentence(formatMessage, String(values.projectType)),
}),
},
},
},
'add-custom-license-details': {
severity: 'error',
evaluate: (context) => {
const license = context.project.license
const missingDetails =
license.id === 'LicenseRef-' ||
(license.id.startsWith('LicenseRef-') &&
!license.url &&
license.id !== 'LicenseRef-Unknown' &&
license.id !== 'LicenseRef-All-Rights-Reserved')
return { valid: Boolean(context.projectV3.minecraft_server) || !missingDetails }
},
presentation: {
message: messages.addDetailsDescription,
nag: { title: messages.addDetails, destination: 'license' },
},
},
'invalid-license-url': {
severity: 'error',
evaluate: (context) => {
const licenseUrl = context.project.license.url
if (!licenseUrl) return { valid: true }
const domain = getLinkHostname(licenseUrl)
if (domain && isInappropriateLicenseLink(licenseUrl)) {
return {
valid: false,
message: messages.invalidUrlDomain,
values: { domain },
}
}
if (!domain) return { valid: false, message: messages.invalidUrlMalformed }
return { valid: true }
},
presentation: {
message: messages.invalidUrlDefault,
nag: {
title: messages.invalidUrl,
destination: 'license',
linkTitle: messages.editLicense,
},
},
},
} satisfies ValidationRuleSet<ProjectValidationContext>
export function getLicenseNags(context: ProjectValidationContext): Nag[] {
return toNags(evaluateRules(context, projectLicenseValidationRules))
}
@@ -0,0 +1,231 @@
import { formatProjectTypeSentence } from '@modrinth/ui'
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { licenseRequiresSource, notSourceAsDistributed } from '../../utils.ts'
import {
getBlockedProjectExternalLink,
isCommonProjectLink,
isDiscordLink,
} from '../../validators/links/index.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { toNags } from '../to-nags.ts'
import type { ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
addLinks: {
id: 'nags.add-links.title',
defaultMessage: 'Add external links',
},
addServerLinks: {
id: 'nags.add-links-server.title',
defaultMessage: 'Add external links',
},
addLinksDescription: {
id: 'nags.add-links.description',
defaultMessage:
'Add any relevant links targeted outside of Modrinth, such as source code, an issue tracker, or a Discord invite.',
},
addServerLinksDescription: {
id: 'nags.add-links-server.description',
defaultMessage:
'Add any relevant links targeted outside of Modrinth, such as a website, store, or a Discord invite.',
},
identicalLinks: {
id: 'nags.identical-links.title',
defaultMessage: 'Clean up identical links',
},
identicalLinksDescription: {
id: 'nags.identical-links.description',
defaultMessage:
'Some of your external links appear to be identical. Each link should be entered only once and with the appropriate link type.',
},
verifyLinks: {
id: 'nags.verify-external-links.title',
defaultMessage: 'Verify external links',
},
verifyLinksDescription: {
id: 'nags.verify-external-links.description',
defaultMessage:
'Some of your external links may be using domains that are inappropriate for that type of link.',
},
moveDiscordInvite: {
id: 'nags.misused-discord-link.title',
defaultMessage: 'Move Discord invite',
},
moveDiscordInviteDescription: {
id: 'nags.misused-discord-link-description',
defaultMessage:
'Discord invites can not be used for other link types. Please put your Discord link in the Discord Invite link field only.',
},
removeBannedLinks: {
id: 'nags.banned-link-usage.title',
defaultMessage: 'Remove prohibited links',
},
removeBannedLinksDescription: {
id: 'nags.banned-link-usage.description',
defaultMessage: '“{url}” is not allowed in project links.',
},
provideSource: {
id: 'nags.gpl-license-source-required.title',
defaultMessage: 'Provide source code',
},
provideSourceDescription: {
id: 'nags.gpl-license-source-required.description',
defaultMessage:
'Your {type} uses a license which requires source code to be available. Please provide a source code link or sources file for each additional version, or consider using a different license.',
},
visitLinks: {
id: 'nags.visit-links-settings.title',
defaultMessage: 'Visit links settings',
},
})
export function findBlockedProjectExternalLink(
context: Pick<ProjectValidationContext, 'project' | 'projectV3'>,
) {
const urls = [
context.project.source_url,
context.project.issues_url,
context.project.wiki_url,
context.project.discord_url,
context.project.license.url,
...(context.project.donation_urls ?? []).map(({ url }) => url),
...Object.values(context.projectV3.link_urls ?? {}).map(({ url }) => url),
]
for (const url of urls) {
if (!url) continue
const blockedLink = getBlockedProjectExternalLink(url)
if (blockedLink) return blockedLink
}
return null
}
export const projectLinksValidationRules = {
'add-links': {
severity: 'suggestion',
evaluate: (context) => ({
valid:
Boolean(context.projectV3.minecraft_server) ||
Object.keys(context.projectV3.link_urls ?? {}).length > 0,
}),
presentation: {
message: messages.addLinksDescription,
nag: { title: messages.addLinks, destination: 'links' },
},
},
'add-links-server': {
severity: 'suggestion',
evaluate: (context) => ({
valid:
!context.projectV3.minecraft_server ||
Object.keys(context.projectV3.link_urls ?? {}).length > 0,
}),
presentation: {
message: messages.addServerLinksDescription,
nag: { title: messages.addServerLinks, destination: 'links' },
},
},
'identical-links': {
severity: 'error',
evaluate: (context) => {
const links = Object.values(context.projectV3.link_urls ?? {}).map(({ url }) => url)
return { valid: new Set(links).size === links.length }
},
presentation: {
message: messages.identicalLinksDescription,
nag: { title: messages.identicalLinks, destination: 'links' },
},
},
'verify-external-links': {
severity: 'warning',
evaluate: (context) => {
const sourceUrl = context.project.source_url
const issuesUrl = context.project.issues_url
const discordUrl = context.project.discord_url
return {
valid: !(
(sourceUrl && !isCommonProjectLink(sourceUrl, 'source')) ||
(issuesUrl && !isCommonProjectLink(issuesUrl, 'issues')) ||
(discordUrl && !isCommonProjectLink(discordUrl, 'discord'))
),
}
},
presentation: {
message: messages.verifyLinksDescription,
nag: {
title: messages.verifyLinks,
destination: 'links',
linkTitle: messages.visitLinks,
},
},
},
'misused-discord-link': {
severity: 'error',
evaluate: (context) => ({
valid: !(
isDiscordLink(context.project.source_url) ||
isDiscordLink(context.project.issues_url) ||
isDiscordLink(context.project.wiki_url) ||
isDiscordLink(context.projectV3.link_urls?.site?.url) ||
isDiscordLink(context.projectV3.link_urls?.store?.url)
),
}),
presentation: {
message: messages.moveDiscordInviteDescription,
nag: {
title: messages.moveDiscordInvite,
destination: 'links',
linkTitle: messages.visitLinks,
},
},
},
'banned-link-usage': {
severity: 'error',
evaluate: (context) => {
const blockedLink = findBlockedProjectExternalLink(context)
return blockedLink ? { valid: false, values: { url: blockedLink.url } } : { valid: true }
},
presentation: {
message: messages.removeBannedLinksDescription,
nag: { title: messages.removeBannedLinks },
},
},
'gpl-license-source-required': {
severity: 'error',
evaluate: (context) => {
if (context.projectV3.project_types.includes('datapack')) return { valid: true }
const hasSourceUrl = Boolean(context.project.source_url)
const everyVersionHasAdditionalFiles = context.versions.every(
(version) => version.files.length >= 2,
)
const requiresSource =
licenseRequiresSource(context.projectV3.license.id) &&
notSourceAsDistributed(context.projectV3.project_types) &&
!hasSourceUrl &&
!everyVersionHasAdditionalFiles
return requiresSource
? { valid: false, values: { projectType: context.project.project_type } }
: { valid: true }
},
presentation: {
message: messages.provideSourceDescription,
nag: {
title: messages.provideSource,
destination: 'links',
linkTitle: messages.visitLinks,
formatValues: (values, formatMessage) => ({
type: formatProjectTypeSentence(formatMessage, String(values.projectType)),
}),
},
},
},
} satisfies ValidationRuleSet<ProjectValidationContext>
export function getLinksNags(context: ProjectValidationContext): Nag[] {
return toNags(evaluateRules(context, projectLinksValidationRules))
}
@@ -0,0 +1,34 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { toNags } from '../to-nags.ts'
import type { ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
title: {
id: 'nags.moderator-feedback.title',
defaultMessage: 'Review feedback',
},
description: {
id: 'nags.moderator-feedback.description',
defaultMessage: 'Review and address all concerns from the moderation team before resubmitting.',
},
})
export const projectModerationValidationRules = {
'moderator-feedback': {
severity: 'warning',
evaluate: (context) => ({
valid: !context.tags.rejectedStatuses.includes(context.project.status),
}),
presentation: {
message: messages.description,
nag: { title: messages.title, destination: 'moderation' },
},
},
} satisfies ValidationRuleSet<ProjectValidationContext>
export function getModerationNags(context: ProjectValidationContext): Nag[] {
return toNags(evaluateRules(context, projectModerationValidationRules))
}
@@ -0,0 +1,149 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, NagContext } from '../../types/nags.ts'
import { validateNonStandardText } from '../../validators/non-standard-text/index.ts'
import { validateProfanity } from '../../validators/profanity/index.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { toFieldMessages } from '../to-field-messages.ts'
import { toNags } from '../to-nags.ts'
import type { FieldValidationMessage, ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
fixName: {
id: 'nags.invalid-project-name.title',
defaultMessage: 'Fix the project name',
},
fixVersion: {
id: 'nags.project-name-version.title',
defaultMessage: 'Fix project name',
},
avoidBrandInfringement: {
id: 'nags.minecraft-title-clause.title',
defaultMessage: 'Avoid brand infringement',
},
editName: {
id: 'nags.edit-title.title',
defaultMessage: 'Edit title',
},
slur: {
id: 'nags.project-name-slur.description',
defaultMessage: 'Your project name cannot contain any slurs. Detected: “{value}”.',
},
profanity: {
id: 'nags.project-name-profanity.description',
defaultMessage: 'Your project name cannot contain profanity. Detected: “{value}”.',
},
nonStandardText: {
id: 'nags.project-name-non-standard-text.description',
defaultMessage: 'Non-standard text characters, such as “₮ɆӾ₮”, are not allowed.',
},
versionNumber: {
id: 'project.text-validation.title-version-number',
defaultMessage: 'Names are not allowed to include version numbers.',
},
minecraftBranding: {
id: 'nags.minecraft-title-clause.description',
defaultMessage:
'Projects must not use Minecraft\'s branding or include "Minecraft" as a significant part of the name.',
},
})
export const projectNameValidationRules = {
'project-name-slur': {
severity: 'error',
evaluate: (projectName) => {
const match = validateProfanity(projectName).matches.find((match) => match.kind === 'slur')
if (match) {
return { valid: false, values: { value: match.rawText } }
} else {
return { valid: true }
}
},
presentation: {
message: messages.slur,
nag: {
title: messages.fixName,
destination: 'general',
linkTitle: messages.editName,
},
},
},
'project-name-profanity': {
severity: 'error',
evaluate: (projectName) => {
const match = validateProfanity(projectName).matches.find(
(match) => match.kind === 'profanity',
)
if (match) {
return { valid: false, values: { value: match.rawText } }
} else {
return { valid: true }
}
},
presentation: {
message: messages.profanity,
nag: {
title: messages.fixName,
destination: 'general',
linkTitle: messages.editName,
},
},
},
'project-name-non-standard-text': {
severity: 'error',
evaluate: (projectName) => ({ valid: validateNonStandardText(projectName).valid }),
presentation: {
message: messages.nonStandardText,
nag: {
title: messages.fixName,
destination: 'general',
linkTitle: messages.editName,
},
},
},
'project-name-version': {
severity: 'error',
evaluate: (projectName) => {
const normalizedName = projectName.normalize('NFC').toLowerCase()
const includesVersionNumber = [...normalizedName.matchAll(/\d+(?:\.\d+)+/g)].some((match) => {
const textAfterVersion = normalizedName.slice((match.index ?? 0) + match[0].length)
return !/\b(?:port|fork)\b/.test(textAfterVersion)
})
return { valid: !includesVersionNumber }
},
presentation: {
message: messages.versionNumber,
nag: {
title: messages.fixVersion,
destination: 'general',
linkTitle: messages.editName,
},
},
},
'minecraft-title-clause': {
severity: 'warning',
evaluate: (projectName) => {
const normalizedName = projectName.normalize('NFC').toLowerCase()
const words = normalizedName.split(/\s+/).filter(Boolean)
return {
valid: !(normalizedName.includes('minecraft') && words.length <= 3),
}
},
presentation: {
message: messages.minecraftBranding,
nag: {
title: messages.avoidBrandInfringement,
destination: 'general',
linkTitle: messages.editName,
},
},
},
} satisfies ValidationRuleSet<string>
export function validateProjectNameField(name: string): FieldValidationMessage[] {
return toFieldMessages(evaluateRules(name, projectNameValidationRules))
}
export function getNameNags(context: Pick<NagContext, 'projectV3'>): Nag[] {
return toNags(evaluateRules(context.projectV3.name, projectNameValidationRules))
}
@@ -0,0 +1,37 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { toNags } from '../to-nags.ts'
import type { ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
title: {
id: 'nags.review-permissions.title',
defaultMessage: 'Review external permissions',
},
description: {
id: 'nags.review-permissions.description',
defaultMessage:
'Make sure you have provided proof of your permission to distribute any external content in your Modpack.',
},
})
export const projectPermissionsValidationRules = {
'review-permissions': {
severity: 'error',
evaluate: (context) => ({
valid: !context.versions.some(
(version) => (version.files_missing_attribution?.length ?? 0) >= 1,
),
}),
presentation: {
message: messages.description,
nag: { title: messages.title, destination: 'permissions' },
},
},
} satisfies ValidationRuleSet<ProjectValidationContext>
export function getPermissionsNags(context: ProjectValidationContext): Nag[] {
return toNags(evaluateRules(context, projectPermissionsValidationRules))
}
@@ -0,0 +1,137 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { toNags } from '../to-nags.ts'
import type { ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
selectCountry: {
id: 'nags.select-country.title',
defaultMessage: 'Select a region',
},
selectCountryDescription: {
id: 'nags.select-country.description',
defaultMessage: 'Let players know what region your server is located in.',
},
selectAccurateLanguages: {
id: 'nags.all-languages.title',
defaultMessage: 'Select accurate languages',
},
allLanguages: {
id: 'nags.all-languages.description',
defaultMessage:
"You've selected all available language options. Please list only the languages your server actively supports.",
},
addJavaAddress: {
id: 'nags.add-java-address.title',
defaultMessage: 'Add a Java address',
},
addJavaAddressDescription: {
id: 'nags.add-java-address.description',
defaultMessage: 'Add the IP address and port Java Edition players can use to join your server.',
},
selectCompatibility: {
id: 'nags.select-compatibility.title',
defaultMessage: 'Select compatibility',
},
selectCompatibilityDescription: {
id: 'nags.select-compatibility.description',
defaultMessage:
'Select what versions your server supports, choose a Modpack, or upload your own.',
},
tooManyLanguages: {
id: 'nags.too-many-languages.title',
defaultMessage: 'Select accurate languages',
},
tooManyLanguagesDescription: {
id: 'nags.too-many-languages.description',
defaultMessage:
"You've selected {languageCount, plural, one {# language} other {# languages}}. Please list only the languages your server actively supports.",
},
selectLanguage: {
id: 'nags.select-language.title',
defaultMessage: 'Select a language',
},
selectLanguageDescription: {
id: 'nags.select-language.description',
defaultMessage: 'List the language or languages supported by your server.',
},
})
export const MAX_LANGUAGE_COUNT = 10
export const projectServerSettingsValidationRules = {
'select-country': {
severity: 'error',
evaluate: (context) => ({
valid:
!context.projectV3.minecraft_server || Boolean(context.projectV3.minecraft_server.region),
}),
presentation: {
message: messages.selectCountryDescription,
nag: { title: messages.selectCountry, destination: 'server' },
},
},
'all-languages': {
severity: 'error',
evaluate: () => ({ valid: true }),
presentation: {
message: messages.allLanguages,
nag: { title: messages.selectAccurateLanguages, destination: 'server' },
},
},
'add-java-address': {
severity: 'error',
evaluate: (context) => ({
valid:
!context.projectV3.minecraft_server ||
Boolean(context.projectV3.minecraft_java_server?.address),
}),
presentation: {
message: messages.addJavaAddressDescription,
nag: { title: messages.addJavaAddress, destination: 'server' },
},
},
'select-compatibility': {
severity: 'error',
evaluate: (context) => ({
valid:
context.projectV3.minecraft_java_server?.content?.kind !== 'vanilla' ||
Boolean(context.projectV3.minecraft_java_server.content.recommended_game_version),
}),
presentation: {
message: messages.selectCompatibilityDescription,
nag: { title: messages.selectCompatibility, destination: 'server' },
},
},
'too-many-languages': {
severity: 'warning',
evaluate: (context) => {
const languageCount = context.projectV3.minecraft_server?.languages?.length ?? 0
return languageCount > MAX_LANGUAGE_COUNT
? { valid: false, values: { languageCount } }
: { valid: true }
},
presentation: {
message: messages.tooManyLanguagesDescription,
nag: { title: messages.tooManyLanguages, destination: 'server' },
},
},
'select-language': {
severity: 'suggestion',
evaluate: (context) => ({
valid:
!context.projectV3.minecraft_server ||
(context.projectV3.minecraft_server.languages?.length ?? 0) > 0,
}),
presentation: {
message: messages.selectLanguageDescription,
nag: { title: messages.selectLanguage, destination: 'server' },
},
},
} satisfies ValidationRuleSet<ProjectValidationContext>
export function getServerSettingsNags(context: ProjectValidationContext): Nag[] {
return toNags(evaluateRules(context, projectServerSettingsValidationRules))
}
@@ -0,0 +1,196 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import {
containsExplicitHttpProjectLink,
findBlockedProjectContentLink,
} from '../../validators/links/detection.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({
fixSummary: {
id: 'nags.invalid-project-summary.title',
defaultMessage: 'Fix the project summary',
},
reviewSummary: {
id: 'nags.project-summary-content.title',
defaultMessage: 'Review the project summary',
},
expandSummary: {
id: 'nags.summary-too-short.title',
defaultMessage: 'Expand the summary',
},
cleanUpSummary: {
id: 'nags.summary-special-formatting.title',
defaultMessage: 'Clean up the summary',
},
editSummary: {
id: 'nags.edit-summary.title',
defaultMessage: 'Edit summary',
},
slur: {
id: 'nags.project-summary-slur.description',
defaultMessage: 'Your project summary cannot contain any slurs. Detected: “{value}”.',
},
profanity: {
id: 'nags.project-summary-profanity.description',
defaultMessage: 'Your project summary cannot contain profanity. Detected: “{value}”.',
},
nonStandardText: {
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.",
},
tooShort: {
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.',
},
specialFormatting: {
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.',
},
})
export const MIN_SUMMARY_CHARS = 30
export interface ProjectSummaryValidationInput {
summary: string | null | undefined
name: string | null | undefined
}
export function projectSummaryMatchesName(summary: string, name: string) {
const normalizedSummary = normalizeProjectFieldText(summary).replace(/\s+/g, '')
const normalizedName = normalizeProjectFieldText(name).replace(/\s+/g, '')
return normalizedSummary.length > 0 && normalizedSummary === normalizedName
}
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),
)
}
const commonNagPresentation = {
destination: 'general',
linkTitle: messages.editSummary,
} as const
export const projectSummaryValidationRules = {
'project-summary-slur': {
severity: 'error',
evaluate: ({ summary }) => evaluateSlur(summary ?? ''),
presentation: {
message: messages.slur,
nag: { title: messages.fixSummary, ...commonNagPresentation },
},
},
'project-summary-profanity': {
severity: 'error',
evaluate: ({ summary }) => evaluateProfanity(summary ?? ''),
presentation: {
message: messages.profanity,
nag: { title: messages.fixSummary, ...commonNagPresentation },
},
},
'project-summary-non-standard-text': {
severity: 'error',
evaluate: ({ summary }) => evaluateNonStandardText(summary ?? ''),
presentation: {
message: messages.nonStandardText,
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) ||
!name ||
!projectSummaryMatchesName(summary, name),
}),
presentation: {
message: messages.matchesName,
nag: { title: messages.reviewSummary, ...commonNagPresentation },
},
},
'summary-too-short': {
severity: 'warning',
evaluate: ({ summary }) => {
if (!summary || containsExplicitHttpProjectLink(summary)) return { valid: true }
const length = normalizeProjectFieldText(summary).length
return length < MIN_SUMMARY_CHARS
? { valid: false, values: { length, minChars: MIN_SUMMARY_CHARS } }
: { valid: true }
},
presentation: {
message: messages.tooShort,
nag: { title: messages.expandSummary, ...commonNagPresentation },
},
},
'summary-special-formatting': {
severity: 'error',
evaluate: ({ summary }) => ({
valid:
!summary ||
(!hasProjectSummaryFormatting(summary) && !containsExplicitHttpProjectLink(summary)),
}),
presentation: {
message: messages.specialFormatting,
nag: { title: messages.cleanUpSummary, ...commonNagPresentation },
},
},
} satisfies ValidationRuleSet<ProjectSummaryValidationInput>
export function validateProjectSummary(
input: ProjectSummaryValidationInput,
): FieldValidationMessage[] {
return toFieldMessages(evaluateRules(input, projectSummaryValidationRules))
}
export function getSummaryNags(context: Pick<ProjectValidationContext, 'projectV3'>): Nag[] {
return toNags(
evaluateRules(
{ summary: context.projectV3.summary, name: context.projectV3.name },
projectSummaryValidationRules,
),
)
}
@@ -0,0 +1,188 @@
import type { Labrinth } from '@modrinth/api-client'
import { formatCategory } from '@modrinth/ui'
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { toNags } from '../to-nags.ts'
import type { ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
selectTags: {
id: 'nags.select-tags.title',
defaultMessage: 'Select tags',
},
selectTagsDescription: {
id: 'nags.select-tags.description',
defaultMessage:
'Select the tags that correctly apply to your project to help the right users find it.',
},
selectAccurateTags: {
id: 'nags.too-many-tags.title',
defaultMessage: 'Select accurate tags',
},
selectAccurateServerTags: {
id: 'nags.too-many-tags-server.title',
defaultMessage: 'Select accurate tags',
},
selectAllTags: {
id: 'nags.all-tags-selected.title',
defaultMessage: 'Select accurate tags',
},
tooManyTags: {
id: 'nags.too-many-tags.description',
defaultMessage:
"You've selected {tagCount, plural, one {# tag} other {# tags}}. Consider reducing to {maxTagCount} or fewer to make sure your project appears in relevant search results.",
},
tooManyServerTags: {
id: 'nags.too-many-tags-server.description',
defaultMessage:
"You've selected {tagCount, plural, one {# tag} other {# tags}}. Please reduce to {maxTagCount} or fewer to make sure your server appears in relevant search results.",
},
selectResolution: {
id: 'nags.multiple-resolution-tags.title',
defaultMessage: 'Select correct resolution',
},
multipleResolutionTags: {
id: 'nags.multiple-resolution-tags.description',
defaultMessage:
"You've selected {count, plural, one {# resolution tag} other {# resolution tags}} ({tags}). Resource packs should typically only have one resolution tag that matches their primary resolution.",
},
allTagsSelected: {
id: 'nags.all-tags-selected.description',
defaultMessage:
"You've selected all {totalAvailableTags, plural, one {# available tag} other {# available tags}}. This defeats the purpose of tags, which are meant to help users find relevant projects. Please select only the tags that are relevant to your project.",
},
editTags: {
id: 'nags.edit-tags.title',
defaultMessage: 'Edit tags',
},
})
export const allResolutionTags = ['8x-', '16x', '32x', '48x', '64x', '128x', '256x', '512x+']
export const MAX_TAG_COUNT = 8
export const MAX_TAG_COUNT_SERVER = 18
function getCategories(
project: Labrinth.Projects.v2.Project & { actualProjectType: string },
tags: ProjectValidationContext['tags'],
) {
return (
tags.categories?.filter((category) => category.project_type === project.actualProjectType) ?? []
)
}
function getSelectedTagCount(context: ProjectValidationContext) {
return context.project.categories.length + (context.project.additional_categories?.length ?? 0)
}
function getResolutionTags(context: ProjectValidationContext) {
return context.project.categories
.concat(context.project.additional_categories ?? [])
.filter((tag) => allResolutionTags.includes(tag))
.toSorted((a, b) => allResolutionTags.indexOf(a) - allResolutionTags.indexOf(b))
}
export const projectTagsValidationRules = {
'select-tags': {
severity: 'suggestion',
evaluate: (context) => ({
valid: context.project.versions.length === 0 || context.project.categories.length > 0,
}),
presentation: {
message: messages.selectTagsDescription,
nag: { title: messages.selectTags, destination: 'tags' },
},
},
'too-many-tags': {
severity: 'warning',
evaluate: (context) => {
const tagCount = getSelectedTagCount(context)
const tooMany =
!context.projectV3.minecraft_java_server &&
!context.projectV3.minecraft_server &&
tagCount > MAX_TAG_COUNT
return tooMany
? { valid: false, values: { tagCount, maxTagCount: MAX_TAG_COUNT } }
: { valid: true }
},
presentation: {
message: messages.tooManyTags,
nag: {
title: messages.selectAccurateTags,
destination: 'tags',
linkTitle: messages.editTags,
},
},
},
'too-many-tags-server': {
severity: 'error',
evaluate: (context) => {
const tagCount = getSelectedTagCount(context)
return context.projectV3.minecraft_server && tagCount > MAX_TAG_COUNT_SERVER
? { valid: false, values: { tagCount, maxTagCount: MAX_TAG_COUNT_SERVER } }
: { valid: true }
},
presentation: {
message: messages.tooManyServerTags,
nag: {
title: messages.selectAccurateServerTags,
destination: 'tags',
linkTitle: messages.editTags,
},
},
},
'multiple-resolution-tags': {
severity: 'warning',
evaluate: (context) => {
const resolutionTags = getResolutionTags(context)
return context.project.project_type === 'resourcepack' && resolutionTags.length > 1
? {
valid: false,
values: { count: resolutionTags.length, tags: resolutionTags.join('|') },
}
: { valid: true }
},
presentation: {
message: messages.multipleResolutionTags,
nag: {
title: messages.selectResolution,
destination: 'tags',
linkTitle: messages.editTags,
formatValues: (values, formatMessage) => ({
count: values.count,
tags: String(values.tags)
.split('|')
.map((tag) => formatCategory(formatMessage, tag))
.join(', '),
}),
},
},
},
'all-tags-selected': {
severity: 'error',
evaluate: (context) => {
const categories = getCategories(
context.project as Labrinth.Projects.v2.Project & { actualProjectType: string },
context.tags,
)
const totalAvailableTags = categories.length
const allSelected =
getSelectedTagCount(context) === totalAvailableTags &&
context.project.project_type !== 'project'
return allSelected ? { valid: false, values: { totalAvailableTags } } : { valid: true }
},
presentation: {
message: messages.allTagsSelected,
nag: {
title: messages.selectAllTags,
destination: 'tags',
linkTitle: messages.editTags,
},
},
},
} satisfies ValidationRuleSet<ProjectValidationContext>
export function getTagsNags(context: ProjectValidationContext): Nag[] {
return toNags(evaluateRules(context, projectTagsValidationRules))
}
@@ -0,0 +1,34 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { toNags } from '../to-nags.ts'
import type { ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
title: {
id: 'nags.upload-version.title',
defaultMessage: 'Upload a version',
},
description: {
id: 'nags.upload-version.description',
defaultMessage: 'At least one version is required for a project to be submitted for review.',
},
})
export const projectVersionValidationRules = {
'upload-version': {
severity: 'error',
evaluate: (context) => ({
valid: context.projectV3.versions.length > 0 || Boolean(context.projectV3.minecraft_server),
}),
presentation: {
message: messages.description,
nag: { title: messages.title, destination: 'versions' },
},
},
} satisfies ValidationRuleSet<ProjectValidationContext>
export function getVersionNags(context: ProjectValidationContext): Nag[] {
return toNags(evaluateRules(context, projectVersionValidationRules))
}