mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 12:05:53 +00:00
refactor: centralize project validation and nags into validation rules
This commit is contained in:
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user