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
@@ -1,11 +1,17 @@
<template>
<div
v-if="check && check.severity !== 'valid'"
class="flex w-full items-center gap-1.5"
:class="check.severity === 'error' ? 'text-red' : 'text-orange'"
>
<component :is="icon" class="my-auto" />
{{ message }}
<div v-if="validations.length > 0" class="flex w-full flex-col gap-1.5">
<div
v-for="(validation, index) in validations"
:key="validation.message?.id ?? index"
class="flex w-full items-center gap-1.5"
:class="validation.severity === 'error' ? 'text-red' : 'text-orange'"
>
<component
:is="validation.severity === 'error' ? XCircleIcon : TriangleAlertIcon"
class="my-auto"
/>
{{ validation.message ? formatMessage(validation.message, validation.values) : undefined }}
</div>
</div>
</template>
@@ -20,16 +26,15 @@ interface ValidationCheck {
values?: Record<string, unknown>
}
const props = withDefaults(defineProps<{ check?: ValidationCheck | null }>(), {
const props = withDefaults(defineProps<{ check?: ValidationCheck | ValidationCheck[] | null }>(), {
check: null,
})
const { formatMessage } = useVIntl()
const icon = computed(() => (props.check?.severity === 'error' ? XCircleIcon : TriangleAlertIcon))
const message = computed(() => {
if (!props.check?.message) return undefined
return formatMessage(props.check.message, props.check.values)
})
const validations = computed(() =>
(Array.isArray(props.check) ? props.check : props.check ? [props.check] : []).filter(
(validation) => validation.severity !== 'valid',
),
)
</script>
@@ -339,7 +339,11 @@ const summaryValidation = useProjectSummaryValidation(description, name)
const disableCreate = computed(() => {
if (hasHitLimit.value) return true
if (nameValidation.value || summaryValidation.value) return true
if (
nameValidation.value.some((validation) => validation.severity === 'error') ||
summaryValidation.value.some((validation) => validation.severity === 'error')
)
return true
if (!name.value.trim() || !slug.value.trim()) return true
if (!manualSlug.value && checkingSlugSuggestions.value) return true
if (!manualSlug.value && !slugSuggestions.value.includes(slug.value)) return true
@@ -32,51 +32,72 @@
</IconButton>
</div>
</div>
<div v-if="!collapsed" class="mt-4 grid grid-cols-[repeat(auto-fit,minmax(15rem,1fr))] gap-2">
<div v-if="!collapsed" class="relative mt-4">
<div
v-for="nag in visibleNags"
:key="nag.id"
class="flex flex-col gap-3 rounded-2xl border border-solid border-surface-5 bg-surface-2 p-4"
class="nag-scroll-shadow-left pointer-events-none absolute bottom-0 left-0 top-0 z-10 w-8 bg-surface-3 transition-opacity duration-200"
:class="showLeftNagShadow ? 'opacity-100' : 'opacity-0'"
/>
<div
ref="nagScroller"
class="flex w-full cursor-grab select-none gap-2 overflow-x-auto overflow-y-hidden pb-2"
:class="{ 'is-dragging': draggingNags }"
@pointerdown="onNagPointerDown"
@pointermove="onNagPointerMove"
@pointerup="finishNagDrag"
@pointercancel="finishNagDrag"
@click.capture="onNagClick"
@wheel="onNagWheel"
@scroll="updateNagScrollShadows"
>
<span class="flex items-center gap-2 font-medium text-contrast">
<component
:is="nag.icon || getDefaultIcon(nag.status)"
v-tooltip="getStatusTooltip(nag.status)"
:class="[
'size-4',
nag.status === 'required' && 'text-red',
nag.status === 'warning' && 'text-orange',
nag.status === 'suggestion' && 'text-purple',
]"
:aria-label="getStatusTooltip(nag.status)"
/>
{{ getFormattedMessage(nag.title) }}
</span>
{{ getNagDescription(nag) }}
<NuxtLink
v-if="nag.link && shouldShowLink(nag)"
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/${
nag.link.path
}`"
class="goto-link mt-auto"
<div
v-for="nag in visibleNags"
:key="nag.id"
class="flex w-72 shrink-0 flex-col gap-3 rounded-2xl border border-solid border-surface-5 bg-surface-2 p-4"
>
{{ getFormattedMessage(nag.link.title) }}
<ChevronRightIcon aria-hidden="true" class="featured-header-chevron" />
</NuxtLink>
<Button
v-if="nag.status === 'special-submit-action' && nag.id === 'submit-for-review'"
v-tooltip="
!canSubmitForReview ? getFormattedMessage(messages.submitChecklistTooltip) : undefined
"
type="colored"
color="orange"
:disabled="!canSubmitForReview"
@click="submitForReview"
>
<SendIcon />
{{ getFormattedMessage(messages.submitForReviewButton) }}
</Button>
<span class="flex items-center gap-2 font-medium text-contrast">
<component
:is="nag.icon || getDefaultIcon(nag.status)"
v-tooltip="getStatusTooltip(nag.status)"
:class="[
'size-4',
nag.status === 'required' && 'text-red',
nag.status === 'warning' && 'text-orange',
nag.status === 'suggestion' && 'text-purple',
]"
:aria-label="getStatusTooltip(nag.status)"
/>
{{ getFormattedMessage(nag.title) }}
</span>
{{ getNagDescription(nag) }}
<NuxtLink
v-if="nag.link && shouldShowLink(nag)"
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/${
nag.link.path
}`"
class="goto-link mt-auto"
>
{{ getFormattedMessage(nag.link.title) }}
<ChevronRightIcon aria-hidden="true" class="featured-header-chevron" />
</NuxtLink>
<Button
v-if="nag.status === 'special-submit-action' && nag.id === 'submit-for-review'"
v-tooltip="
!canSubmitForReview ? getFormattedMessage(messages.submitChecklistTooltip) : undefined
"
type="colored"
color="orange"
:disabled="!canSubmitForReview"
@click="submitForReview"
>
<SendIcon />
{{ getFormattedMessage(messages.submitForReviewButton) }}
</Button>
</div>
</div>
<div
class="nag-scroll-shadow-right pointer-events-none absolute bottom-0 right-0 top-0 z-10 w-8 bg-surface-3 transition-opacity duration-200"
:class="showRightNagShadow ? 'opacity-100' : 'opacity-0'"
/>
</div>
</div>
</template>
@@ -92,15 +113,17 @@ import {
SendIcon,
TriangleAlertIcon,
} from '@modrinth/assets'
import type { Nag, NagContext, NagStatus } from '@modrinth/moderation'
import { nags } from '@modrinth/moderation'
import type { Nag, NagContext, NagStatus, ProjectTitleMetadata } from '@modrinth/moderation'
import { nags, validateProjectFields } from '@modrinth/moderation'
import { Button, IconButton } from '@modrinth/ui'
import { defineMessages, type MessageDescriptor, useVIntl } from '@modrinth/ui'
import type { Component } from 'vue'
import { computed } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
interface Tags {
rejectedStatuses: string[]
gameVersions: { version: string }[]
loaders: { name: string }[]
}
interface Props {
@@ -176,9 +199,125 @@ const emit = defineEmits<{
setProcessing: [processing: boolean]
}>()
const nagScroller = ref<HTMLElement | null>(null)
const showLeftNagShadow = ref(false)
const showRightNagShadow = ref(false)
const draggingNags = ref(false)
let nagScrollerResizeObserver: ResizeObserver | null = null
let nagDragPointerId: number | null = null
let nagDragCaptureTarget: Element | null = null
let nagDragStartX = 0
let nagDragStartScrollLeft = 0
let suppressNagClick = false
let suppressNagClickTimeout: ReturnType<typeof setTimeout> | null = null
function updateNagScrollShadows() {
const el = nagScroller.value
if (!el) {
showLeftNagShadow.value = false
showRightNagShadow.value = false
return
}
showLeftNagShadow.value = el.scrollLeft > 0
showRightNagShadow.value = el.scrollLeft < el.scrollWidth - el.clientWidth - 1
}
function onNagWheel(event: WheelEvent) {
const el = nagScroller.value
if (!el || el.scrollWidth <= el.clientWidth) return
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY
event.preventDefault()
el.scrollLeft += delta
}
function onNagPointerDown(event: PointerEvent) {
const el = nagScroller.value
if (!el || event.pointerType === 'touch' || event.button !== 0) return
nagDragPointerId = event.pointerId
nagDragStartX = event.clientX
nagDragStartScrollLeft = el.scrollLeft
suppressNagClick = false
nagDragCaptureTarget =
event.target instanceof Element ? (event.target.closest('a, button') ?? el) : el
nagDragCaptureTarget.setPointerCapture(event.pointerId)
}
function onNagPointerMove(event: PointerEvent) {
const el = nagScroller.value
if (!el || event.pointerId !== nagDragPointerId) return
const distance = event.clientX - nagDragStartX
if (!draggingNags.value && Math.abs(distance) < 4) return
draggingNags.value = true
suppressNagClick = true
event.preventDefault()
el.scrollLeft = nagDragStartScrollLeft - distance
}
function finishNagDrag(event: PointerEvent) {
if (event.pointerId !== nagDragPointerId) return
if (nagDragCaptureTarget?.hasPointerCapture(event.pointerId)) {
nagDragCaptureTarget.releasePointerCapture(event.pointerId)
}
nagDragPointerId = null
nagDragCaptureTarget = null
draggingNags.value = false
if (suppressNagClick) {
if (suppressNagClickTimeout) clearTimeout(suppressNagClickTimeout)
suppressNagClickTimeout = setTimeout(() => {
suppressNagClick = false
suppressNagClickTimeout = null
}, 0)
}
}
function onNagClick(event: MouseEvent) {
if (!suppressNagClick) return
event.preventDefault()
event.stopPropagation()
suppressNagClick = false
if (suppressNagClickTimeout) clearTimeout(suppressNagClickTimeout)
suppressNagClickTimeout = null
}
onMounted(() => {
nagScrollerResizeObserver = new ResizeObserver(updateNagScrollShadows)
if (nagScroller.value) nagScrollerResizeObserver.observe(nagScroller.value)
nextTick(updateNagScrollShadows)
})
onBeforeUnmount(() => {
nagScrollerResizeObserver?.disconnect()
if (suppressNagClickTimeout) clearTimeout(suppressNagClickTimeout)
})
watch(nagScroller, (el, previousEl) => {
if (previousEl) nagScrollerResizeObserver?.unobserve(previousEl)
if (el) nagScrollerResizeObserver?.observe(el)
nextTick(updateNagScrollShadows)
})
const titleMetadata = computed<ProjectTitleMetadata>(() => ({
gameVersions: props.tags.gameVersions.map(({ version }) => version),
loaders: props.tags.loaders.map(({ name }) => name),
}))
const projectValidation = computed(() =>
validateProjectFields(props.projectV3, titleMetadata.value),
)
const nagContext = computed<NagContext>(() => ({
project: props.project,
projectV3: props.projectV3,
projectValidation: projectValidation.value,
versions: props.versions,
currentMember: props.currentMember?.user as Labrinth.Users.v2.User,
currentRoute: props.routeName,
@@ -247,6 +386,8 @@ const visibleNags = computed<Nag[]>(() => {
return finalNags
})
watch(visibleNags, () => nextTick(updateNagScrollShadows))
function shouldShowLink(nag: Nag): boolean {
return nag.link?.shouldShow ? nag.link.shouldShow(nagContext.value) : false
}
@@ -298,4 +439,19 @@ function getFormattedMessage(message: string | MessageDescriptor): string {
.duration-250 {
transition-duration: 250ms;
}
.is-dragging,
.is-dragging * {
cursor: grabbing !important;
}
.nag-scroll-shadow-left {
-webkit-mask-image: linear-gradient(to right, black, transparent);
mask-image: linear-gradient(to right, black, transparent);
}
.nag-scroll-shadow-right {
-webkit-mask-image: linear-gradient(to left, black, transparent);
mask-image: linear-gradient(to left, black, transparent);
}
</style>
@@ -98,9 +98,10 @@ export function useProjectDescriptionValidation(
requestId++
})
const validation = computed<ProjectTextValidationResult | LinkCheckResult | null>(
() => validateProjectDescription(toValue(description)) ?? linkValidation.value,
)
const validation = computed<Array<ProjectTextValidationResult | LinkCheckResult>>(() => [
...validateProjectDescription(toValue(description)),
...(linkValidation.value ? [linkValidation.value] : []),
])
return {
pending,
+3 -1
View File
@@ -139,7 +139,9 @@
v-if="
projectV3 &&
currentMember &&
(projectV3.status === 'draft' || tags.rejectedStatuses.includes(projectV3.status))
(projectV3.status === 'draft' ||
projectV3.status === 'processing' ||
tags.rejectedStatuses.includes(projectV3.status))
"
:project="project"
:project-v3="projectV3"
@@ -386,7 +386,9 @@ const shouldPreventActions = ref(false)
const galleryTitleValidation = computed(() => validateProjectText(editTitle.value))
const galleryDescriptionValidation = computed(() => validateProjectText(editDescription.value))
const galleryFieldsInvalid = computed(
() => !!galleryTitleValidation.value || !!galleryDescriptionValidation.value,
() =>
galleryTitleValidation.value.some((validation) => validation.severity === 'error') ||
galleryDescriptionValidation.value.some((validation) => validation.severity === 'error'),
)
// Constant for accepted file types
@@ -166,7 +166,7 @@ const moderatorSeeUserUi = computed<boolean>({
<ModerationProjectNags
v-if="
projectV3 &&
((currentMember && project.status === 'draft') ||
((currentMember && (project.status === 'draft' || project.status === 'processing')) ||
tags.rejectedStatuses.includes(project.status))
"
:project="project"
@@ -21,11 +21,6 @@
:on-image-upload="onUploadHandler"
/>
<ValidationMessage :check="descriptionValidation" class="mt-2" />
<div v-if="descriptionWarning" class="mt-2">
<SettingsInlineWarning>
{{ descriptionWarning }}
</SettingsInlineWarning>
</div>
</div>
<UnsavedChangesPopup
:original="saved"
@@ -44,13 +39,11 @@
</template>
<script lang="ts" setup>
import { countText, MIN_DESCRIPTION_CHARS } from '@modrinth/moderation'
import {
commonProjectSettingsMessages,
ConfirmLeaveModal,
injectProjectPageContext,
MarkdownEditor,
SettingsInlineWarning,
UnsavedChangesPopup,
usePageLeaveSafety,
useSavable,
@@ -96,7 +89,9 @@ const hasPermission = computed(
)
const { pending: descriptionLinksPending, validation: descriptionValidation } =
useProjectDescriptionValidation(() => current.value.description)
const hasValidationIssues = computed(() => descriptionValidation.value?.severity === 'error')
const hasValidationIssues = computed(() =>
descriptionValidation.value.some((validation) => validation.severity === 'error'),
)
const canSave = computed(
() => hasPermission.value && !hasValidationIssues.value && !descriptionLinksPending.value,
)
@@ -106,17 +101,6 @@ async function save() {
await saveForm()
}
const descriptionWarning = computed(() => {
const text = current.value.description?.trim() || ''
const charCount = countText(text)
if (charCount < MIN_DESCRIPTION_CHARS) {
return `It's recommended to have a description with at least ${MIN_DESCRIPTION_CHARS} characters. (${charCount}/${MIN_DESCRIPTION_CHARS})`
}
return null
})
async function onUploadHandler(file: File) {
if (await fileDeclaresAi(file)) {
aiImageWarningModal.value?.show()
@@ -346,7 +346,9 @@ const shouldPreventActions = ref(false)
const galleryTitleValidation = computed(() => validateProjectText(editTitle.value))
const galleryDescriptionValidation = computed(() => validateProjectText(editDescription.value))
const galleryFieldsInvalid = computed(
() => !!galleryTitleValidation.value || !!galleryDescriptionValidation.value,
() =>
galleryTitleValidation.value.some((validation) => validation.severity === 'error') ||
galleryDescriptionValidation.value.some((validation) => validation.severity === 'error'),
)
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
@@ -64,11 +64,6 @@
resize="vertical"
/>
<ValidationMessage :check="summaryValidation" class="mt-2" />
<div v-if="summaryWarning" class="my-2">
<SettingsInlineWarning>
{{ summaryWarning }}
</SettingsInlineWarning>
</div>
</div>
<div>
@@ -295,7 +290,7 @@
:saving="saving"
:can-save="canSave"
:save-disabled-reason="
hasPermission && hasValidationIssues
hasPermission && hasBlockingValidationIssues
? projectTextValidationMessages.resolveIssuesToSave
: undefined
"
@@ -308,7 +303,6 @@
<script setup>
import { ImageIcon, ScaleIcon, TrashIcon, UploadIcon } from '@modrinth/assets'
import { MIN_SUMMARY_CHARS } from '@modrinth/moderation'
import {
Avatar,
Button,
@@ -435,8 +429,13 @@ const hasPermission = computed(() => {
const nameValidation = useProjectTitleValidation(name)
const summaryValidation = useProjectSummaryValidation(summary, name)
const hasValidationIssues = computed(() => !!nameValidation.value || !!summaryValidation.value)
const canSave = computed(() => hasPermission.value && !hasValidationIssues.value)
const hasValidationIssues = computed(
() =>
nameValidation.value.some((validation) => validation.severity === 'error') ||
summaryValidation.value.some((validation) => validation.severity === 'error'),
)
const hasBlockingValidationIssues = computed(() => hasValidationIssues.value && !isStaff.value)
const canSave = computed(() => hasPermission.value && !hasBlockingValidationIssues.value)
const monetizationToggleDisabled = computed(() => !hasPermission.value || isForceDemonetized.value)
@@ -445,17 +444,6 @@ const hasDeletePermission = computed(() => {
return ((currentMember.value?.permissions ?? 0) & DELETE_PROJECT) === DELETE_PROJECT
})
const summaryWarning = computed(() => {
const text = summary.value?.trim() || ''
const charCount = text.length
if (charCount < MIN_SUMMARY_CHARS) {
return `It's recommended to have a summary with at least ${MIN_SUMMARY_CHARS} characters. (${charCount}/${MIN_SUMMARY_CHARS})`
}
return null
})
const visibilityOptions = computed(() =>
tags.value.approvedStatuses
.filter((status) => status !== 'archived')
+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)
})