mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 12:05:53 +00:00
feat: update nags with field validation, splitting between required/warning nags
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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,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',
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -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}”."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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\n\n\n`
|
||||
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)
|
||||
})
|
||||
Reference in New Issue
Block a user