feat: update nags with field validation, splitting between required/warning nags

This commit is contained in:
tdgao
2026-08-26 11:21:48 -06:00
parent 0d61960855
commit a348240686
22 changed files with 1189 additions and 598 deletions
+2 -2
View File
@@ -1,14 +1,14 @@
import type { Nag } from '../types/nags'
import { coreNags } from './nags/core'
import { descriptionNags } from './nags/description'
import { linksNags } from './nags/links'
import { projectValidationNags } from './nags/project-validation'
import { serverProjectsNags } from './nags/server-projects'
import { tagsNags } from './nags/tags'
export default [
...coreNags,
...linksNags,
...descriptionNags,
...projectValidationNags,
...tagsNags,
...serverProjectsNags,
] as Nag[]
-23
View File
@@ -49,29 +49,6 @@ export const coreNags: Nag[] = [
context.currentRoute !== 'type-project-settings-versions',
},
},
{
id: 'add-description',
title: defineMessage({
id: 'nags.add-description.title',
defaultMessage: 'Add a description',
}),
description: defineMessage({
id: 'nags.add-description.description',
defaultMessage:
"A description that clearly describes the project's purpose and function is required.",
}),
status: 'required',
shouldShow: (context: NagContext) => context.project.body === '',
link: {
path: 'settings/description',
title: defineMessage({
id: 'nags.settings.description.title',
defaultMessage: 'Visit description settings',
}),
shouldShow: (context: NagContext) =>
context.currentRoute !== 'type-project-settings-description',
},
},
{
id: 'add-icon',
title: defineMessage({
@@ -1,400 +0,0 @@
import { defineMessage, useVIntl } from '@modrinth/ui'
import { renderHighlightedString } from '@modrinth/utils'
import type { Nag, NagContext } from '../../types/nags'
export const MIN_DESCRIPTION_CHARS = 200
export const MAX_HEADER_LENGTH = 80
export const MIN_SUMMARY_CHARS = 30
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 headerRegex = /^(#{1,3})\s+(.+)$/gm
const headers = [...withoutCodeBlocks.matchAll(headerRegex)]
const longHeaders: string[] = []
headers.forEach((match) => {
const headerText = match[2].trim()
const sentenceEnders = /[.!?]+/g
const sentences = headerText.split(sentenceEnders).filter((s) => s.trim().length > 0)
const isVeryLong = headerText.length > MAX_HEADER_LENGTH
const hasMultipleSentences = sentences.length > 1
if (isVeryLong || hasMultipleSentences) {
longHeaders.push(headerText)
}
})
return {
hasLongHeaders: longHeaders.length > 0,
longHeaders,
}
}
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 imageRegex = /!\[([^\]]*)\]\([^)]+\)/g
const images = [...withoutCodeBlocks.matchAll(imageRegex)]
const htmlImageRegex = /<img[^>]*>/gi
const htmlImages = [...withoutCodeBlocks.matchAll(htmlImageRegex)]
const totalImages = images.length + htmlImages.length
if (totalImages === 0) return { imageHeavy: false, hasEmptyAltText: false }
const textLength = countText(withoutCodeBlocks)
const recommendedTextLength = MIN_CHARS_PER_IMAGE * totalImages
const imageHeavy =
recommendedTextLength > MIN_DESCRIPTION_CHARS && textLength < recommendedTextLength
const hasEmptyAltText =
images.some((match) => !match[1]?.trim()) ||
htmlImages.some((match) => {
const altMatch = match[0].match(/alt\s*=\s*["']([^"']*)["']/i)
return !altMatch || !altMatch[1]?.trim()
})
return { imageHeavy, hasEmptyAltText }
}
export function countText(markdown: string): number {
if (!markdown) return 0
const fallback = (md: string): number => {
const withoutCode = md.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '')
const withoutImagesAndLinks = withoutCode
.replace(/!\[[^\]]*]\([^)]+\)/g, ' ')
.replace(/\[[^\]]*]\([^)]+\)/g, ' ')
const withoutHtml = withoutImagesAndLinks.replace(/<[^>]+>/g, ' ')
const withoutMdSyntax = withoutHtml
.replace(/^>{1}\s?.*$/gm, ' ')
.replace(/^#{1,6}\s+/gm, ' ')
.replace(/[*_~`>-]/g, ' ')
.replace(/\|/g, ' ')
return withoutMdSyntax.replace(/\s+/g, ' ').trim().length
}
if (typeof window === 'undefined' || typeof globalThis.DOMParser === 'undefined') {
console.warn(`[Moderation] SSR: no window/DOMParser, falling back for countText`)
return fallback(markdown)
}
try {
const htmlString = renderHighlightedString(markdown)
const parser = new DOMParser()
const doc = parser.parseFromString(htmlString, 'text/html')
const walker = doc.createTreeWalker(doc.body || doc, NodeFilter.SHOW_TEXT)
const textList: string[] = []
let node = walker.nextNode()
while (node) {
if (node.textContent) textList.push(node.textContent)
node = walker.nextNode()
}
return textList.join(' ').replace(/\s+/g, ' ').trim().length
} catch {
return fallback(markdown)
}
}
export const descriptionNags: Nag[] = [
{
id: 'description-too-short',
title: defineMessage({
id: 'nags.description-too-short.title',
defaultMessage: 'Expand the description',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const readableLength = countText(context.project.body || '')
return formatMessage(
defineMessage({
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.',
}),
{
length: readableLength,
minChars: MIN_DESCRIPTION_CHARS,
},
)
},
status: 'warning',
shouldShow: (context: NagContext) => {
const readableLength = countText(context.project.body || '')
return readableLength < MIN_DESCRIPTION_CHARS && readableLength > 0
},
link: {
path: 'settings/description',
title: defineMessage({
id: 'nags.edit-description.title',
defaultMessage: 'Edit description',
}),
shouldShow: (context: NagContext) =>
context.currentRoute !== 'type-project-settings-description',
},
},
{
id: 'long-headers',
title: defineMessage({
id: 'nags.long-headers.title',
defaultMessage: 'Shorten headers',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const { longHeaders } = analyzeHeaderLength(context.project.body || '')
const count = longHeaders.length
return formatMessage(
defineMessage({
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.',
}),
{
count,
},
)
},
status: 'warning',
shouldShow: (context: NagContext) => {
const { hasLongHeaders } = analyzeHeaderLength(context.project.body || '')
return hasLongHeaders
},
link: {
path: 'settings/description',
title: defineMessage({
id: 'nags.edit-description.title',
defaultMessage: 'Edit description',
}),
shouldShow: (context: NagContext) =>
context.currentRoute !== 'type-project-settings-description',
},
},
{
id: 'summary-too-short',
title: defineMessage({
id: 'nags.summary-too-short.title',
defaultMessage: 'Expand the summary',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
return formatMessage(
defineMessage({
id: 'nags.summary-too-short.description',
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.',
}),
{
length: context.project.description?.length || 0,
minChars: MIN_SUMMARY_CHARS,
},
)
},
status: 'warning',
shouldShow: (context: NagContext) => {
const summaryLength = context.project.description?.trim()?.length || 0
return summaryLength < MIN_SUMMARY_CHARS && summaryLength !== 0
},
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-summary.title',
defaultMessage: 'Edit summary',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings',
},
},
{
id: 'summary-special-formatting',
title: defineMessage({
id: 'nags.summary-special-formatting.title',
defaultMessage: 'Clear up the summary',
}),
description: defineMessage({
id: 'nags.summary-special-formatting.description',
defaultMessage: `Your summary should not contain formatting, line breaks, special characters, or links, since the summary will only display plain text.`,
}),
status: 'warning',
shouldShow: (context: NagContext) => {
const summary = context.project.description?.trim() || ''
return Boolean(
summary.match(/https:\/\//g) ||
summary.match(/http:\/\//g) ||
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) ||
summary.match(/```/g) ||
summary.match(/> /g),
)
},
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-summary.title',
defaultMessage: 'Edit summary',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings',
},
},
{
id: 'minecraft-title-clause',
title: defineMessage({
id: 'nags.minecraft-title-clause.title',
defaultMessage: 'Avoid brand infringement',
}),
description: defineMessage({
id: 'nags.minecraft-title-clause.description',
defaultMessage: `Projects must not use Minecraft's branding or include "Minecraft" as a significant part of the name.`,
}),
status: 'warning',
shouldShow: (context: NagContext) => {
const title = context.project.title?.toLowerCase() || ''
const wordsInTitle = title.split(' ').filter((word) => word.length > 0)
return title.includes('minecraft') && title.length > 0 && wordsInTitle.length <= 3
},
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-title.title',
defaultMessage: 'Edit title',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings',
},
},
{
id: 'title-contains-technical-info',
title: defineMessage({
id: 'nags.title-contains-technical-info.title',
defaultMessage: 'Clean up the name',
}),
description: defineMessage({
id: 'nags.title-contains-technical-info.description',
defaultMessage:
"Keeping your project's Name clean makes it memorable and easier to find. Version and loader information is automatically displayed alongside your project.",
}),
status: 'warning',
shouldShow: (context: NagContext) => {
const title = context.project.title?.toLowerCase() || ''
if (!title) return false
const loaderNames =
context.tags.loaders?.map((loader: { name: string }) => loader.name?.toLowerCase()) || []
const hasLoader = loaderNames.some((loader) => loader && title.includes(loader.toLowerCase()))
const versionPatterns = [/\b1\.\d+(\.\d+)?\b/]
const hasVersionPattern = versionPatterns.some((pattern) => pattern.test(title))
return hasLoader || hasVersionPattern
},
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-title.title',
defaultMessage: 'Edit title',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings',
},
},
{
id: 'summary-same-as-title',
title: defineMessage({
id: 'nags.summary-same-as-title.title',
defaultMessage: 'Make the summary unique',
}),
description: defineMessage({
id: 'nags.summary-same-as-title.description',
defaultMessage:
"Your summary can not be the same as your project's Name. It's important to create an informative and enticing Summary.",
}),
status: 'required',
shouldShow: (context: NagContext) => {
const title = context.project.title?.trim() || ''
const summary = context.project.description?.trim() || ''
return title === summary && title.length > 0 && summary.length > 0
},
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-summary.title',
defaultMessage: 'Edit summary',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings',
},
},
{
// Don't like this one, is this needed?
id: 'image-heavy-description',
title: defineMessage({
id: 'nags.image-heavy-description.title',
defaultMessage: 'Ensure accessibility',
}),
description: defineMessage({
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.',
}),
status: 'warning',
shouldShow: (context: NagContext) => {
const { imageHeavy } = analyzeImageContent(context.project.body || '')
return imageHeavy
},
link: {
path: 'settings/description',
title: defineMessage({
id: 'nags.edit-description.title',
defaultMessage: 'Edit description',
}),
shouldShow: (context: NagContext) =>
context.currentRoute !== 'type-project-settings-description',
},
},
{
id: 'missing-alt-text',
title: defineMessage({
id: 'nags.missing-alt-text.title',
defaultMessage: 'Add image alt text',
}),
description: defineMessage({
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.',
}),
status: 'warning',
shouldShow: (context: NagContext) => {
const { hasEmptyAltText } = analyzeImageContent(context.project.body || '')
return hasEmptyAltText
},
link: {
path: 'settings/description',
title: defineMessage({
id: 'nags.edit-description.title',
defaultMessage: 'Edit description',
}),
shouldShow: (context: NagContext) =>
context.currentRoute !== 'type-project-settings-description',
},
},
]
+1 -1
View File
@@ -1,5 +1,5 @@
export * from './core'
export * from './description'
export * from './links'
export * from './project-validation'
export * from './server-projects'
export * from './tags'
@@ -0,0 +1,357 @@
import { defineMessage, useVIntl } from '@modrinth/ui'
import type { Nag, NagContext } from '../../types/nags'
import type { ProjectTextValidationCode } from '../../validators/project-fields/index.ts'
import type {
ProjectValidationFailure,
ProjectValidationField,
} from '../../validators/project-validation/index.ts'
const generalSettingsRoutes = new Set(['type-project-settings', 'type-project-settings-general'])
const nameErrorCodes: readonly ProjectTextValidationCode[] = [
'text-slur',
'text-profanity',
'text-non-standard',
]
const nameWarningCodes: readonly ProjectTextValidationCode[] = [
'title-game-version',
'title-loader',
]
const summaryErrorCodes: readonly ProjectTextValidationCode[] = [
'text-slur',
'text-profanity',
'text-non-standard',
]
const summaryWarningCodes: readonly ProjectTextValidationCode[] = [
'summary-link',
'summary-matches-title',
]
const descriptionErrorCodes: readonly ProjectTextValidationCode[] = [
'text-slur',
'text-profanity',
'text-non-standard',
]
const messages = {
galleryFailure: defineMessage({
id: 'nags.invalid-gallery-text.description',
defaultMessage:
'Gallery image {number} {field, select, gallery-name {name} gallery-description {description} other {text}}: {error}',
}),
}
function getFirstFailure(
context: NagContext,
fields: readonly ProjectValidationField[],
severity?: ProjectValidationFailure['severity'],
codes?: readonly ProjectTextValidationCode[],
): ProjectValidationFailure | undefined {
return context.projectValidation.failures.find(
(failure) =>
fields.includes(failure.field) &&
(!severity || failure.severity === severity) &&
(!codes || codes.includes(failure.code)),
)
}
function getFailureDescription(
context: NagContext,
fields: readonly ProjectValidationField[],
severity?: ProjectValidationFailure['severity'],
codes?: readonly ProjectTextValidationCode[],
): string {
const failure = getFirstFailure(context, fields, severity, codes)
if (!failure) return ''
const { formatMessage } = useVIntl()
return formatMessage(failure.message, failure.values)
}
function getCodedFailure(context: NagContext, code: ProjectTextValidationCode) {
return context.projectValidation.failures.find((failure) => failure.code === code)
}
function getCodedFailureDescription(context: NagContext, code: ProjectTextValidationCode) {
const failure = getCodedFailure(context, code)
if (!failure) return ''
const { formatMessage } = useVIntl()
return formatMessage(failure.message, failure.values)
}
function getGalleryFailureDescription(context: NagContext): string {
const failure = getFirstFailure(context, ['gallery-name', 'gallery-description'], 'error')
if (!failure) return ''
const { formatMessage } = useVIntl()
return formatMessage(messages.galleryFailure, {
number: (failure.galleryIndex ?? 0) + 1,
field: failure.field,
error: formatMessage(failure.message, failure.values),
})
}
export const projectValidationNags: Nag[] = [
{
id: 'invalid-project-name',
title: defineMessage({
id: 'nags.invalid-project-name.title',
defaultMessage: 'Fix the project name',
}),
description: (context) => getFailureDescription(context, ['name'], 'error', nameErrorCodes),
status: 'required',
shouldShow: (context) =>
getFirstFailure(context, ['name'], 'error', nameErrorCodes) !== undefined,
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-title.title',
defaultMessage: 'Edit title',
}),
shouldShow: (context) => !generalSettingsRoutes.has(context.currentRoute),
},
},
{
id: 'project-name-metadata',
title: defineMessage({
id: 'nags.project-name-metadata.title',
defaultMessage: 'Remove technical details from the name',
}),
description: (context) => getFailureDescription(context, ['name'], 'warn', nameWarningCodes),
status: 'warning',
shouldShow: (context) =>
getFirstFailure(context, ['name'], 'warn', nameWarningCodes) !== undefined,
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-title.title',
defaultMessage: 'Edit title',
}),
shouldShow: (context) => !generalSettingsRoutes.has(context.currentRoute),
},
},
{
id: 'minecraft-title-clause',
title: defineMessage({
id: 'nags.minecraft-title-clause.title',
defaultMessage: 'Avoid brand infringement',
}),
description: (context) => getCodedFailureDescription(context, 'title-minecraft-branding'),
status: 'warning',
shouldShow: (context) => getCodedFailure(context, 'title-minecraft-branding') !== undefined,
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-title.title',
defaultMessage: 'Edit title',
}),
shouldShow: (context) => !generalSettingsRoutes.has(context.currentRoute),
},
},
{
id: 'invalid-project-summary',
title: defineMessage({
id: 'nags.invalid-project-summary.title',
defaultMessage: 'Fix the project summary',
}),
description: (context) =>
getFailureDescription(context, ['summary'], 'error', summaryErrorCodes),
status: 'required',
shouldShow: (context) =>
getFirstFailure(context, ['summary'], 'error', summaryErrorCodes) !== undefined,
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-summary.title',
defaultMessage: 'Edit summary',
}),
shouldShow: (context) => !generalSettingsRoutes.has(context.currentRoute),
},
},
{
id: 'project-summary-content',
title: defineMessage({
id: 'nags.project-summary-content.title',
defaultMessage: 'Review the project summary',
}),
description: (context) =>
getFailureDescription(context, ['summary'], 'warn', summaryWarningCodes),
status: 'warning',
shouldShow: (context) =>
getFirstFailure(context, ['summary'], 'warn', summaryWarningCodes) !== undefined,
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-summary.title',
defaultMessage: 'Edit summary',
}),
shouldShow: (context) => !generalSettingsRoutes.has(context.currentRoute),
},
},
{
id: 'summary-too-short',
title: defineMessage({
id: 'nags.summary-too-short.title',
defaultMessage: 'Expand the summary',
}),
description: (context) => getCodedFailureDescription(context, 'summary-too-short'),
status: 'warning',
shouldShow: (context) => getCodedFailure(context, 'summary-too-short') !== undefined,
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-summary.title',
defaultMessage: 'Edit summary',
}),
shouldShow: (context) => !generalSettingsRoutes.has(context.currentRoute),
},
},
{
id: 'summary-special-formatting',
title: defineMessage({
id: 'nags.summary-special-formatting.title',
defaultMessage: 'Clean up the summary',
}),
description: (context) => getCodedFailureDescription(context, 'summary-special-formatting'),
status: 'warning',
shouldShow: (context) => getCodedFailure(context, 'summary-special-formatting') !== undefined,
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-summary.title',
defaultMessage: 'Edit summary',
}),
shouldShow: (context) => !generalSettingsRoutes.has(context.currentRoute),
},
},
{
id: 'add-description',
title: defineMessage({
id: 'nags.add-description.title',
defaultMessage: 'Add a description',
}),
description: (context) => getCodedFailureDescription(context, 'description-required'),
status: 'required',
shouldShow: (context) => getCodedFailure(context, 'description-required') !== undefined,
link: {
path: 'settings/description',
title: defineMessage({
id: 'nags.settings.description.title',
defaultMessage: 'Visit description settings',
}),
shouldShow: (context) => context.currentRoute !== 'type-project-settings-description',
},
},
{
id: 'invalid-project-description',
title: defineMessage({
id: 'nags.invalid-project-description.title',
defaultMessage: 'Fix the project description',
}),
description: (context) =>
getFailureDescription(context, ['description'], 'error', descriptionErrorCodes),
status: 'required',
shouldShow: (context) =>
getFirstFailure(context, ['description'], 'error', descriptionErrorCodes) !== undefined,
link: {
path: 'settings/description',
title: defineMessage({
id: 'nags.edit-description.title',
defaultMessage: 'Edit description',
}),
shouldShow: (context) => context.currentRoute !== 'type-project-settings-description',
},
},
{
id: 'description-too-short',
title: defineMessage({
id: 'nags.description-too-short.title',
defaultMessage: 'Expand the description',
}),
description: (context) => getCodedFailureDescription(context, 'description-too-short'),
status: 'warning',
shouldShow: (context) => getCodedFailure(context, 'description-too-short') !== undefined,
link: {
path: 'settings/description',
title: defineMessage({
id: 'nags.edit-description.title',
defaultMessage: 'Edit description',
}),
shouldShow: (context) => context.currentRoute !== 'type-project-settings-description',
},
},
{
id: 'long-headers',
title: defineMessage({
id: 'nags.long-headers.title',
defaultMessage: 'Shorten headers',
}),
description: (context) => getCodedFailureDescription(context, 'description-long-headers'),
status: 'warning',
shouldShow: (context) => getCodedFailure(context, 'description-long-headers') !== undefined,
link: {
path: 'settings/description',
title: defineMessage({
id: 'nags.edit-description.title',
defaultMessage: 'Edit description',
}),
shouldShow: (context) => context.currentRoute !== 'type-project-settings-description',
},
},
{
id: 'image-heavy-description',
title: defineMessage({
id: 'nags.image-heavy-description.title',
defaultMessage: 'Ensure accessibility',
}),
description: (context) => getCodedFailureDescription(context, 'description-image-heavy'),
status: 'warning',
shouldShow: (context) => getCodedFailure(context, 'description-image-heavy') !== undefined,
link: {
path: 'settings/description',
title: defineMessage({
id: 'nags.edit-description.title',
defaultMessage: 'Edit description',
}),
shouldShow: (context) => context.currentRoute !== 'type-project-settings-description',
},
},
{
id: 'missing-alt-text',
title: defineMessage({
id: 'nags.missing-alt-text.title',
defaultMessage: 'Add image alt text',
}),
description: (context) => getCodedFailureDescription(context, 'description-missing-alt-text'),
status: 'warning',
shouldShow: (context) => getCodedFailure(context, 'description-missing-alt-text') !== undefined,
link: {
path: 'settings/description',
title: defineMessage({
id: 'nags.edit-description.title',
defaultMessage: 'Edit description',
}),
shouldShow: (context) => context.currentRoute !== 'type-project-settings-description',
},
},
{
id: 'invalid-gallery-text',
title: defineMessage({
id: 'nags.invalid-gallery-text.title',
defaultMessage: 'Fix gallery text',
}),
description: getGalleryFailureDescription,
status: 'required',
shouldShow: (context) =>
getFirstFailure(context, ['gallery-name', 'gallery-description'], 'error') !== undefined,
link: {
path: 'settings/gallery',
title: defineMessage({
id: 'nags.edit-gallery.title',
defaultMessage: 'Edit gallery',
}),
shouldShow: (context) => context.currentRoute !== 'type-project-settings-gallery',
},
},
]
+1
View File
@@ -23,3 +23,4 @@ export * from './validators/link-checks'
export * from './validators/non-standard-text'
export * from './validators/profanity'
export * from './validators/project-fields'
export * from './validators/project-validation'
@@ -62,6 +62,9 @@
"nags.edit-description.title": {
"defaultMessage": "Edit description"
},
"nags.edit-gallery.title": {
"defaultMessage": "Edit gallery"
},
"nags.edit-license.title": {
"defaultMessage": "Edit license"
},
@@ -101,6 +104,12 @@
"nags.image-heavy-description.title": {
"defaultMessage": "Ensure accessibility"
},
"nags.invalid-gallery-text.description": {
"defaultMessage": "Gallery image {number} {field, select, gallery-name {name} gallery-description {description} other {text}}: {error}"
},
"nags.invalid-gallery-text.title": {
"defaultMessage": "Fix gallery text"
},
"nags.invalid-license-url.description.default": {
"defaultMessage": "License URL is invalid."
},
@@ -113,6 +122,15 @@
"nags.invalid-license-url.title": {
"defaultMessage": "Add a valid license link"
},
"nags.invalid-project-description.title": {
"defaultMessage": "Fix the project description"
},
"nags.invalid-project-name.title": {
"defaultMessage": "Fix the project name"
},
"nags.invalid-project-summary.title": {
"defaultMessage": "Fix the project summary"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "Use of link shorteners or other methods to obscure where a link may lead in your external links or license link is prohibited, please only use appropriate full length links."
},
@@ -212,6 +230,12 @@
"nags.multiple-resolution-tags.title": {
"defaultMessage": "Select correct resolution"
},
"nags.project-name-metadata.title": {
"defaultMessage": "Remove technical details from the name"
},
"nags.project-summary-content.title": {
"defaultMessage": "Review the project summary"
},
"nags.review-permissions.description": {
"defaultMessage": "Make sure you have provided proof of your permission to distribute any external content in your Modpack."
},
@@ -275,30 +299,15 @@
"nags.settings.versions.title": {
"defaultMessage": "Visit versions settings"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "Your summary can not be the same as your project's Name. It's important to create an informative and enticing Summary."
},
"nags.summary-same-as-title.title": {
"defaultMessage": "Make the summary unique"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "Your summary should not contain formatting, line breaks, special characters, or links, since the summary will only display plain text."
"defaultMessage": "Your summary should not contain formatting, line breaks, or special characters, since the summary will only display plain text."
},
"nags.summary-special-formatting.title": {
"defaultMessage": "Clear up the summary"
},
"nags.summary-too-short.description": {
"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."
"defaultMessage": "Clean up the summary"
},
"nags.summary-too-short.title": {
"defaultMessage": "Expand the summary"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "Keeping your project's Name clean makes it memorable and easier to find. Version and loader information is automatically displayed alongside your project."
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "Clean up the name"
},
"nags.too-many-languages.description": {
"defaultMessage": "You've selected {languageCount, plural, one {# language} other {# languages}}. Please list only the languages your server actively supports."
},
@@ -354,15 +363,18 @@
"defaultMessage": "Slurs are not allowed."
},
"project.text-validation.summary-link": {
"defaultMessage": "Links are not allowed in project summaries."
"defaultMessage": "Links should not be included in project summaries."
},
"project.text-validation.summary-matches-title": {
"defaultMessage": "A project summary cannot be the same as its title."
"defaultMessage": "A project summary should not be the same as its title."
},
"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."
},
"project.text-validation.title-game-version": {
"defaultMessage": "Project titles cannot include the Minecraft version “{value}”."
"defaultMessage": "Project titles should not include the Minecraft version “{value}”."
},
"project.text-validation.title-loader": {
"defaultMessage": "Project titles cannot include the loader “{value}”."
"defaultMessage": "Project titles should not include the loader “{value}”."
}
}
+6
View File
@@ -2,6 +2,8 @@ import type { Labrinth } from '@modrinth/api-client'
import type { MessageDescriptor } from '@modrinth/ui'
import type { FunctionalComponent, SVGAttributes } from 'vue'
import type { ProjectValidationResult } from '../validators/project-validation/index.ts'
/**
* Type which represents the status type of a nag.
*
@@ -25,6 +27,10 @@ export interface NagContext {
* The project V3 associated with the nag.
*/
projectV3: Labrinth.Projects.v3.Project
/**
* Validation results for editable project text fields.
*/
projectValidation: ProjectValidationResult
/**
* The versions associated with the project.
*/
@@ -9,21 +9,45 @@ export interface ProjectFieldMessageDescriptor {
description?: string
}
function defineMessages<T extends Record<string, ProjectFieldMessageDescriptor>>(descriptors: T): T {
function defineMessages<T extends Record<string, ProjectFieldMessageDescriptor>>(
descriptors: T,
): T {
return descriptors
}
export interface ProjectTextValidationResult {
severity: 'error'
code: ProjectTextValidationCode
severity: 'warn' | 'error'
message: ProjectFieldMessageDescriptor
values?: Record<string, unknown>
}
export type ProjectTextValidationCode =
| 'text-slur'
| 'text-profanity'
| 'text-non-standard'
| 'title-game-version'
| 'title-loader'
| 'title-minecraft-branding'
| 'summary-link'
| 'summary-matches-title'
| 'summary-too-short'
| 'summary-special-formatting'
| 'description-required'
| 'description-too-short'
| 'description-long-headers'
| 'description-image-heavy'
| 'description-missing-alt-text'
export interface ProjectTextValidationOptions {
nonStandardTextFailureThreshold?: number
}
export const DESCRIPTION_NON_STANDARD_TEXT_FAILURE_THRESHOLD = 0.05
export const MIN_DESCRIPTION_CHARS = 200
export const MAX_HEADER_LENGTH = 80
export const MIN_CHARS_PER_IMAGE = 60
export const MIN_SUMMARY_CHARS = 30
const messages = defineMessages({
slur: {
@@ -40,19 +64,59 @@ const messages = defineMessages({
},
titleGameVersion: {
id: 'project.text-validation.title-game-version',
defaultMessage: 'Project titles cannot include the Minecraft version “{value}”.',
defaultMessage: 'Project titles should not include the Minecraft version “{value}”.',
},
titleLoader: {
id: 'project.text-validation.title-loader',
defaultMessage: 'Project titles cannot include the loader “{value}”.',
defaultMessage: 'Project titles should not include the loader “{value}”.',
},
titleMinecraftBranding: {
id: 'nags.minecraft-title-clause.description',
defaultMessage:
'Projects must not use Minecraft\'s branding or include "Minecraft" as a significant part of the name.',
},
summaryLink: {
id: 'project.text-validation.summary-link',
defaultMessage: 'Links are not allowed in project summaries.',
defaultMessage: 'Links should not be included in project summaries.',
},
summaryMatchesTitle: {
id: 'project.text-validation.summary-matches-title',
defaultMessage: 'A project summary cannot be the same as its title.',
defaultMessage: 'A project summary should not be the same as its title.',
},
summaryTooShort: {
id: 'project.text-validation.summary-too-short',
defaultMessage:
'Your summary is {length, plural, one {# character} other {# characters}}. At least {minChars, plural, one {# character} other {# characters}} is recommended to create an informative and enticing summary.',
},
summarySpecialFormatting: {
id: 'nags.summary-special-formatting.description',
defaultMessage:
'Your summary should not contain formatting, line breaks, or special characters, since the summary will only display plain text.',
},
descriptionRequired: {
id: 'nags.add-description.description',
defaultMessage:
"A description that clearly describes the project's purpose and function is required.",
},
descriptionTooShort: {
id: 'nags.description-too-short.description',
defaultMessage:
'Your description is {length, plural, one {# readable character} other {# readable characters}}. At least {minChars, plural, one {# character} other {# characters}} is recommended to create a clear and informative description.',
},
descriptionLongHeaders: {
id: 'nags.long-headers.description',
defaultMessage:
'{count, plural, one {# header} other {# headers}} in your description {count, plural, one {is} other {are}} too long. Headers should be concise and act as section titles, not full sentences.',
},
descriptionImageHeavy: {
id: 'nags.image-heavy-description.description',
defaultMessage:
'Your Description should contain sufficient plain text or image alt-text, keeping it accessible to those using screen readers or with slow internet connections.',
},
descriptionMissingAltText: {
id: 'nags.missing-alt-text.description',
defaultMessage:
'Some of your images are missing alt text, which is important for accessibility, especially for visually impaired users.',
},
})
@@ -125,18 +189,97 @@ export function containsProjectLinkOrIp(text: string) {
return linkify.test(text)
}
export function hasProjectSummaryFormatting(summary: string) {
return Boolean(
summary.match(/# .*/g) ||
summary.match(/---/g) ||
summary.match(/\n/g) ||
summary.match(/`.*`/g) ||
summary.match(/\*.*\*/g) ||
summary.match(/_.*_/g) ||
summary.match(/~~.*~~/g) ||
summary.match(/```/g) ||
summary.match(/> /g),
)
}
export function analyzeHeaderLength(markdown: string): {
hasLongHeaders: boolean
longHeaders: string[]
} {
if (!markdown) return { hasLongHeaders: false, longHeaders: [] }
const withoutCodeBlocks = markdown.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '')
const headers = [...withoutCodeBlocks.matchAll(/^(#{1,3})\s+(.+)$/gm)]
const longHeaders = headers
.map((match) => match[2].trim())
.filter((headerText) => {
const sentences = headerText.split(/[.!?]+/g).filter((sentence) => sentence.trim().length > 0)
return headerText.length > MAX_HEADER_LENGTH || sentences.length > 1
})
return {
hasLongHeaders: longHeaders.length > 0,
longHeaders,
}
}
export function countText(markdown: string): number {
if (!markdown) return 0
const withoutCode = markdown.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '')
const withoutImagesAndLinks = withoutCode
.replace(/!\[[^\]]*]\([^)]+\)/g, ' ')
.replace(/\[[^\]]*]\([^)]+\)/g, ' ')
const withoutHtml = withoutImagesAndLinks.replace(/<[^>]+>/g, ' ')
const withoutMarkdownSyntax = withoutHtml
.replace(/^>{1}\s?.*$/gm, ' ')
.replace(/^#{1,6}\s+/gm, ' ')
.replace(/[*_~`>-]/g, ' ')
.replace(/\|/g, ' ')
return withoutMarkdownSyntax.replace(/\s+/g, ' ').trim().length
}
export function analyzeImageContent(markdown: string): {
imageHeavy: boolean
hasEmptyAltText: boolean
} {
if (!markdown) return { imageHeavy: false, hasEmptyAltText: false }
const withoutCodeBlocks = markdown.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '')
const images = [...withoutCodeBlocks.matchAll(/!\[([^\]]*)\]\([^)]+\)/g)]
const htmlImages = [...withoutCodeBlocks.matchAll(/<img[^>]*>/gi)]
const totalImages = images.length + htmlImages.length
if (totalImages === 0) return { imageHeavy: false, hasEmptyAltText: false }
const textLength = countText(withoutCodeBlocks)
const recommendedTextLength = MIN_CHARS_PER_IMAGE * totalImages
const imageHeavy =
recommendedTextLength > MIN_DESCRIPTION_CHARS && textLength < recommendedTextLength
const hasEmptyAltText =
images.some((match) => !match[1]?.trim()) ||
htmlImages.some((match) => {
const altMatch = match[0].match(/alt\s*=\s*["']([^"']*)["']/i)
return !altMatch || !altMatch[1]?.trim()
})
return { imageHeavy, hasEmptyAltText }
}
export function validateProjectText(
text: string | null | undefined,
options: ProjectTextValidationOptions = {},
): ProjectTextValidationResult | null {
if (!text) return null
): ProjectTextValidationResult[] {
if (!text) return []
const profanity = validateProfanity(text)
if (profanity.slurCount > 0) {
return { severity: 'error', message: messages.slur }
return [{ code: 'text-slur', severity: 'error', message: messages.slur }]
}
if (profanity.profanityCount > 0) {
return { severity: 'error', message: messages.profanity }
return [{ code: 'text-profanity', severity: 'error', message: messages.profanity }]
}
const nonStandardText = validateNonStandardText(text)
@@ -145,51 +288,138 @@ export function validateProjectText(
!nonStandardText.valid &&
getNonStandardTextRatio(text, nonStandardText) >= nonStandardTextFailureThreshold
) {
return { severity: 'error', message: messages.nonStandardText }
return [{ code: 'text-non-standard', severity: 'error', message: messages.nonStandardText }]
}
return null
return []
}
export function validateProjectTitle(
text: string | null | undefined,
metadata: ProjectTitleMetadata,
): ProjectTextValidationResult | null {
const textValidation = validateProjectText(text)
if (textValidation || !text) return textValidation
): ProjectTextValidationResult[] {
const results = validateProjectText(text)
if (results.length > 0 || !text) return results
const match = findProjectTitleMetadata(text, metadata)
if (!match) return null
return {
severity: 'error',
message: titleMetadataMessages[match.kind],
values: { value: match.value },
if (match) {
results.push({
code: match.kind === 'game-version' ? 'title-game-version' : 'title-loader',
severity: 'warn',
message: titleMetadataMessages[match.kind],
values: { value: match.value },
})
}
const normalizedTitle = normalizeProjectFieldText(text).toLowerCase()
const wordsInTitle = normalizedTitle.split(/\s+/).filter(Boolean)
if (normalizedTitle.includes('minecraft') && wordsInTitle.length <= 3) {
results.push({
code: 'title-minecraft-branding',
severity: 'warn',
message: messages.titleMinecraftBranding,
})
}
return results
}
export function validateProjectSummary(
summary: string | null | undefined,
title: string | null | undefined,
): ProjectTextValidationResult | null {
const textValidation = validateProjectText(summary)
if (textValidation || !summary) return textValidation
): ProjectTextValidationResult[] {
const results = validateProjectText(summary)
if (results.length > 0 || !summary) return results
if (containsProjectLinkOrIp(summary)) {
return { severity: 'error', message: messages.summaryLink }
return [{ code: 'summary-link', severity: 'warn', message: messages.summaryLink }]
}
if (title && projectSummaryMatchesTitle(summary, title)) {
return { severity: 'error', message: messages.summaryMatchesTitle }
return [
{
code: 'summary-matches-title',
severity: 'warn',
message: messages.summaryMatchesTitle,
},
]
}
return null
const length = normalizeProjectFieldText(summary).length
if (length < MIN_SUMMARY_CHARS) {
results.push({
code: 'summary-too-short',
severity: 'warn',
message: messages.summaryTooShort,
values: { length, minChars: MIN_SUMMARY_CHARS },
})
}
if (hasProjectSummaryFormatting(summary)) {
results.push({
code: 'summary-special-formatting',
severity: 'warn',
message: messages.summarySpecialFormatting,
})
}
return results
}
export function validateProjectDescription(
description: string | null | undefined,
): ProjectTextValidationResult | null {
return validateProjectText(description, {
): ProjectTextValidationResult[] {
const results = validateProjectText(description, {
nonStandardTextFailureThreshold: DESCRIPTION_NON_STANDARD_TEXT_FAILURE_THRESHOLD,
})
if (results.length > 0) return results
const normalizedDescription = normalizeProjectFieldText(description ?? '')
if (!normalizedDescription) {
return [
{
code: 'description-required',
severity: 'error',
message: messages.descriptionRequired,
},
]
}
const readableLength = countText(normalizedDescription)
if (readableLength < MIN_DESCRIPTION_CHARS) {
results.push({
code: 'description-too-short',
severity: 'warn',
message: messages.descriptionTooShort,
values: { length: readableLength, minChars: MIN_DESCRIPTION_CHARS },
})
}
const { hasLongHeaders, longHeaders } = analyzeHeaderLength(normalizedDescription)
if (hasLongHeaders) {
results.push({
code: 'description-long-headers',
severity: 'warn',
message: messages.descriptionLongHeaders,
values: { count: longHeaders.length },
})
}
const { imageHeavy, hasEmptyAltText } = analyzeImageContent(normalizedDescription)
if (imageHeavy) {
results.push({
code: 'description-image-heavy',
severity: 'warn',
message: messages.descriptionImageHeavy,
})
}
if (hasEmptyAltText) {
results.push({
code: 'description-missing-alt-text',
severity: 'warn',
message: messages.descriptionMissingAltText,
})
}
return results
}
@@ -54,48 +54,98 @@ test('extracts and deduplicates normalized links', () => {
})
test('validates shared project text', () => {
assert.equal(validateProjectText('An ordinary project'), null)
assert.deepEqual(validateProjectText('An ordinary project'), [])
assert.equal(
validateProjectText('This project is shit')?.message.id,
validateProjectText('This project is shit')[0]?.message.id,
'project.text-validation.profanity',
)
assert.equal(
validateProjectText('𝐅ancy project')?.message.id,
validateProjectText('𝐅ancy project')[0]?.message.id,
'project.text-validation.non-standard-text',
)
})
test('validates project title metadata', () => {
assert.deepEqual(validateProjectTitle('Fabric Tools', metadata), {
severity: 'error',
message: {
id: 'project.text-validation.title-loader',
defaultMessage: 'Project titles cannot include the loader “{value}”.',
test('validates project titles', () => {
assert.deepEqual(validateProjectTitle('Fabric Tools', metadata), [
{
code: 'title-loader',
severity: 'warn',
message: {
id: 'project.text-validation.title-loader',
defaultMessage: 'Project titles should not include the loader “{value}”.',
},
values: { value: 'fabric' },
},
values: { value: 'fabric' },
})
assert.equal(validateProjectTitle('Ordinary Tools', metadata), null)
])
assert.equal(
validateProjectTitle('Minecraft Tools', metadata)[0]?.code,
'title-minecraft-branding',
)
assert.deepEqual(
validateProjectTitle('Minecraft Fabric Tools', metadata).map(({ code }) => code),
['title-loader', 'title-minecraft-branding'],
)
assert.deepEqual(validateProjectTitle('Ordinary Tools', metadata), [])
})
test('validates project summaries', () => {
assert.equal(
validateProjectSummary('Visit modrinth.com', 'Project title')?.message.id,
validateProjectSummary('Visit modrinth.com', 'Project title')[0]?.message.id,
'project.text-validation.summary-link',
)
assert.equal(validateProjectSummary('Visit modrinth.com', 'Project title')[0]?.severity, 'warn')
assert.equal(
validateProjectSummary(' Caf\u00e9 ', 'Cafe\u0301')?.message.id,
validateProjectSummary(' Caf\u00e9 ', 'Cafe\u0301')[0]?.message.id,
'project.text-validation.summary-matches-title',
)
assert.equal(validateProjectSummary('Project summary', 'Project title'), null)
assert.equal(validateProjectSummary(' Caf\u00e9 ', 'Cafe\u0301')[0]?.severity, 'warn')
assert.deepEqual(validateProjectSummary('Short summary', 'Project title'), [
{
code: 'summary-too-short',
severity: 'warn',
message: {
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.',
},
values: { length: 13, minChars: 30 },
},
])
assert.deepEqual(
validateProjectSummary('A detailed summary of this excellent project', 'Project title'),
[],
)
assert.deepEqual(
validateProjectSummary('# Short summary', 'Project title').map(({ code }) => code),
['summary-too-short', 'summary-special-formatting'],
)
})
test('allows sparse non-standard text in descriptions but rejects it at the threshold', () => {
const belowFivePercent = '𝐀'.concat('a'.repeat(20))
const exactlyFivePercent = '𝐀'.concat('a'.repeat(19))
assert.equal(validateProjectDescription(belowFivePercent), null)
assert.equal(
validateProjectDescription(exactlyFivePercent)?.message.id,
validateProjectDescription(belowFivePercent).some(({ code }) => code === 'text-non-standard'),
false,
)
assert.equal(
validateProjectDescription(exactlyFivePercent)[0]?.message.id,
'project.text-validation.non-standard-text',
)
})
test('validates required description content and returns simultaneous recommendations', () => {
assert.equal(validateProjectDescription(' ')[0]?.code, 'description-required')
const description = `${'# '.concat('A'.repeat(81))}\n![](one.png)\n![](two.png)\n![](three.png)\n![](four.png)`
assert.deepEqual(
validateProjectDescription(description).map(({ code }) => code),
[
'description-too-short',
'description-long-headers',
'description-image-heavy',
'description-missing-alt-text',
],
)
})
@@ -0,0 +1,75 @@
import type { Labrinth } from '@modrinth/api-client'
import {
type ProjectTextValidationResult,
type ProjectTitleMetadata,
validateProjectDescription,
validateProjectSummary,
validateProjectText,
validateProjectTitle,
} from '../project-fields/index.ts'
export type ProjectValidationField =
| 'name'
| 'summary'
| 'description'
| 'gallery-name'
| 'gallery-description'
export interface ProjectValidationFailure extends ProjectTextValidationResult {
field: ProjectValidationField
galleryIndex?: number
galleryUrl?: string
}
export interface ProjectValidationResult {
valid: boolean
failures: ProjectValidationFailure[]
}
export function validateProjectFields(
project: Labrinth.Projects.v3.Project,
titleMetadata: ProjectTitleMetadata,
): ProjectValidationResult {
const failures: ProjectValidationFailure[] = []
function addFailures(
field: ProjectValidationField,
results: ProjectTextValidationResult[],
details: Pick<ProjectValidationFailure, 'galleryIndex' | 'galleryUrl'> = {},
) {
failures.push(
...results.map((result) => ({
...result,
field,
...details,
})),
)
}
addFailures('name', validateProjectTitle(project.name, titleMetadata))
addFailures('summary', validateProjectSummary(project.summary, project.name))
addFailures('description', validateProjectDescription(project.description))
project.gallery.forEach((item, galleryIndex) => {
const details = {
galleryIndex,
galleryUrl: item.url,
}
addFailures('gallery-name', validateProjectText(item.name), details)
addFailures('gallery-description', validateProjectText(item.description), details)
})
return {
valid: !failures.some((failure) => failure.severity === 'error'),
failures,
}
}
export function hasProjectFieldValidationFailures(
project: Labrinth.Projects.v3.Project,
titleMetadata: ProjectTitleMetadata,
): boolean {
return !validateProjectFields(project, titleMetadata).valid
}
@@ -0,0 +1,139 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { Labrinth } from '@modrinth/api-client'
import type { ProjectTitleMetadata } from '../project-fields/index.ts'
import { hasProjectFieldValidationFailures, validateProjectFields } from './index.ts'
const metadata: ProjectTitleMetadata = {
gameVersions: ['1.21.1'],
loaders: ['fabric'],
}
function createProject(
overrides: Partial<Labrinth.Projects.v3.Project> = {},
): Labrinth.Projects.v3.Project {
return {
name: 'Ordinary Tools',
summary: 'A collection of ordinary tools.',
description: 'This project adds a collection of ordinary tools. '.repeat(5),
gallery: [],
...overrides,
} as Labrinth.Projects.v3.Project
}
test('validates project fields and gallery text', () => {
const project = createProject({
name: 'Fabric Tools',
summary: 'Fabric Tools',
description: '𝐀',
gallery: [
{
url: 'https://cdn.modrinth.com/gallery.png',
raw_url: 'https://cdn.modrinth.com/gallery.png',
featured: false,
name: 'This is shit',
description: '𝐁',
created: '2026-01-01T00:00:00Z',
ordering: 0,
},
],
})
const result = validateProjectFields(project, metadata)
assert.equal(result.valid, false)
assert.deepEqual(
result.failures.map(({ field, galleryIndex, galleryUrl, message }) => ({
field,
galleryIndex,
galleryUrl,
message: message.id,
})),
[
{
field: 'name',
galleryIndex: undefined,
galleryUrl: undefined,
message: 'project.text-validation.title-loader',
},
{
field: 'summary',
galleryIndex: undefined,
galleryUrl: undefined,
message: 'project.text-validation.summary-matches-title',
},
{
field: 'description',
galleryIndex: undefined,
galleryUrl: undefined,
message: 'project.text-validation.non-standard-text',
},
{
field: 'gallery-name',
galleryIndex: 0,
galleryUrl: 'https://cdn.modrinth.com/gallery.png',
message: 'project.text-validation.profanity',
},
{
field: 'gallery-description',
galleryIndex: 0,
galleryUrl: 'https://cdn.modrinth.com/gallery.png',
message: 'project.text-validation.non-standard-text',
},
],
)
})
test('reports whether a project has field validation failures', () => {
const validProject = createProject()
const invalidProject = createProject({ summary: 'This project is shit' })
assert.deepEqual(validateProjectFields(validProject, metadata), {
valid: true,
failures: [],
})
assert.equal(hasProjectFieldValidationFailures(validProject, metadata), false)
assert.equal(hasProjectFieldValidationFailures(invalidProject, metadata), true)
})
test('treats title metadata and summary content recommendations as warnings', () => {
const project = createProject({
name: 'Fabric Tools',
summary: 'Visit modrinth.com for more information',
})
const result = validateProjectFields(project, metadata)
assert.equal(result.valid, true)
assert.deepEqual(
result.failures.map(({ code, severity }) => ({ code, severity })),
[
{ code: 'title-loader', severity: 'warn' },
{ code: 'summary-link', severity: 'warn' },
],
)
assert.equal(hasProjectFieldValidationFailures(project, metadata), false)
})
test('reports summary recommendations without invalidating the project', () => {
const project = createProject({ summary: 'Short summary' })
assert.deepEqual(validateProjectFields(project, metadata), {
valid: true,
failures: [
{
code: 'summary-too-short',
field: 'summary',
severity: 'warn',
message: {
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.',
},
values: { length: 13, minChars: 30 },
},
],
})
assert.equal(hasProjectFieldValidationFailures(project, metadata), false)
})