mirror of
https://github.com/modrinth/code.git
synced 2026-08-30 11:36:05 +00:00
feat: implement more project field validators
This commit is contained in:
@@ -9,13 +9,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { TriangleAlertIcon, XCircleIcon } from '@modrinth/assets'
|
import { TriangleAlertIcon, XCircleIcon } from '@modrinth/assets'
|
||||||
import { useVIntl } from '@modrinth/ui'
|
import { type MessageDescriptor, useVIntl } from '@modrinth/ui'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
interface ValidationCheck {
|
||||||
check: { type: Object, default: null },
|
severity: 'valid' | 'warn' | 'error'
|
||||||
|
message?: MessageDescriptor
|
||||||
|
values?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{ check?: ValidationCheck | null }>(), {
|
||||||
|
check: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
|||||||
@@ -161,7 +161,10 @@ import { computed, defineAsyncComponent, h } from 'vue'
|
|||||||
|
|
||||||
import ValidationMessage from '~/components/ValidationMessage.vue'
|
import ValidationMessage from '~/components/ValidationMessage.vue'
|
||||||
import SlugSuggestions from '~/components/ui/SlugSuggestions.vue'
|
import SlugSuggestions from '~/components/ui/SlugSuggestions.vue'
|
||||||
import { validateProjectText } from '~/composables/project-text-validation'
|
import {
|
||||||
|
useProjectSummaryValidation,
|
||||||
|
useProjectTitleValidation,
|
||||||
|
} from '~/composables/project-field-validation'
|
||||||
import {
|
import {
|
||||||
useProjectSlugSuggestions,
|
useProjectSlugSuggestions,
|
||||||
useSlugSuggestionVisibility,
|
useSlugSuggestionVisibility,
|
||||||
@@ -331,8 +334,8 @@ const visibilities = ref<VisibilityOption[]>([
|
|||||||
])
|
])
|
||||||
const visibility = ref<VisibilityOption>(visibilities.value[0])
|
const visibility = ref<VisibilityOption>(visibilities.value[0])
|
||||||
|
|
||||||
const nameValidation = computed(() => validateProjectText(name.value))
|
const nameValidation = useProjectTitleValidation(name)
|
||||||
const summaryValidation = computed(() => validateProjectText(description.value))
|
const summaryValidation = useProjectSummaryValidation(description, name)
|
||||||
|
|
||||||
const disableCreate = computed(() => {
|
const disableCreate = computed(() => {
|
||||||
if (hasHitLimit.value) return true
|
if (hasHitLimit.value) return true
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import {
|
||||||
|
checkLink,
|
||||||
|
extractProjectLinks,
|
||||||
|
getLinkCheckState,
|
||||||
|
type LinkCheckContext,
|
||||||
|
type LinkCheckResult,
|
||||||
|
type ProjectTextValidationResult,
|
||||||
|
type ProjectTitleMetadata,
|
||||||
|
validateProjectDescription,
|
||||||
|
validateProjectSummary,
|
||||||
|
validateProjectTitle,
|
||||||
|
} from '@modrinth/moderation'
|
||||||
|
import { defineMessages } from '@modrinth/ui'
|
||||||
|
import { computed, type MaybeRefOrGetter, onScopeDispose, ref, toValue, watch } from 'vue'
|
||||||
|
|
||||||
|
export const projectTextValidationMessages = defineMessages({
|
||||||
|
resolveIssuesToSave: {
|
||||||
|
id: 'project.text-validation.resolve-issues-to-save',
|
||||||
|
defaultMessage: 'Resolve the issues from your edits to save.',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function useProjectTitleMetadata() {
|
||||||
|
const generatedState = useGeneratedState()
|
||||||
|
|
||||||
|
return computed<ProjectTitleMetadata>(() => ({
|
||||||
|
gameVersions: generatedState.value.gameVersions.map(({ version }) => version),
|
||||||
|
loaders: generatedState.value.loaders.map(({ name }) => name),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProjectTitleValidation(text: MaybeRefOrGetter<string | null | undefined>) {
|
||||||
|
const metadata = useProjectTitleMetadata()
|
||||||
|
return computed(() => validateProjectTitle(toValue(text), metadata.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProjectSummaryValidation(
|
||||||
|
summary: MaybeRefOrGetter<string | null | undefined>,
|
||||||
|
title: MaybeRefOrGetter<string | null | undefined>,
|
||||||
|
) {
|
||||||
|
return computed(() => validateProjectSummary(toValue(summary), toValue(title)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProjectDescriptionValidation(
|
||||||
|
description: MaybeRefOrGetter<string | null | undefined>,
|
||||||
|
) {
|
||||||
|
const linkValidation = ref<LinkCheckResult | null>(null)
|
||||||
|
const pending = ref(false)
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let requestId = 0
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => toValue(description),
|
||||||
|
(text) => {
|
||||||
|
clearTimeout(debounceTimer)
|
||||||
|
const currentRequestId = ++requestId
|
||||||
|
linkValidation.value = null
|
||||||
|
|
||||||
|
if (import.meta.server) return
|
||||||
|
|
||||||
|
const links = extractProjectLinks(text ?? '')
|
||||||
|
if (links.length === 0) {
|
||||||
|
pending.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pending.value = true
|
||||||
|
debounceTimer = setTimeout(async () => {
|
||||||
|
const contexts: LinkCheckContext[] = links.map((url) => ({
|
||||||
|
field: 'description',
|
||||||
|
generalContent: true,
|
||||||
|
url,
|
||||||
|
}))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all(contexts.map((context) => checkLink(context)))
|
||||||
|
if (currentRequestId !== requestId) return
|
||||||
|
|
||||||
|
const checks = contexts
|
||||||
|
.map((context) => getLinkCheckState(context))
|
||||||
|
.filter((check): check is LinkCheckResult => check !== undefined)
|
||||||
|
linkValidation.value =
|
||||||
|
checks.find((check) => check.severity === 'error') ??
|
||||||
|
checks.find((check) => check.severity === 'warn') ??
|
||||||
|
null
|
||||||
|
} catch {
|
||||||
|
if (currentRequestId === requestId) linkValidation.value = null
|
||||||
|
} finally {
|
||||||
|
if (currentRequestId === requestId) pending.value = false
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onScopeDispose(() => {
|
||||||
|
clearTimeout(debounceTimer)
|
||||||
|
requestId++
|
||||||
|
})
|
||||||
|
|
||||||
|
const validation = computed<ProjectTextValidationResult | LinkCheckResult | null>(
|
||||||
|
() => validateProjectDescription(toValue(description)) ?? linkValidation.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
pending,
|
||||||
|
validation,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { validateNonStandardText, validateProfanity } from '@modrinth/moderation'
|
|
||||||
import { defineMessages, type MessageDescriptor } from '@modrinth/ui'
|
|
||||||
|
|
||||||
export interface ProjectTextValidationResult {
|
|
||||||
severity: 'error'
|
|
||||||
message: MessageDescriptor
|
|
||||||
}
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
slur: {
|
|
||||||
id: 'project.text-validation.slur',
|
|
||||||
defaultMessage: 'Slurs are not allowed.',
|
|
||||||
},
|
|
||||||
profanity: {
|
|
||||||
id: 'project.text-validation.profanity',
|
|
||||||
defaultMessage: 'Profanity is not allowed.',
|
|
||||||
},
|
|
||||||
nonStandardText: {
|
|
||||||
id: 'project.text-validation.non-standard-text',
|
|
||||||
defaultMessage: 'Non-standard text characters are not allowed.',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export function validateProjectText(
|
|
||||||
text: string | null | undefined,
|
|
||||||
): ProjectTextValidationResult | null {
|
|
||||||
if (!text) return null
|
|
||||||
|
|
||||||
const profanity = validateProfanity(text)
|
|
||||||
if (profanity.slurCount > 0) {
|
|
||||||
return { severity: 'error', message: messages.slur }
|
|
||||||
}
|
|
||||||
if (profanity.profanityCount > 0) {
|
|
||||||
return { severity: 'error', message: messages.profanity }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!validateNonStandardText(text).valid) {
|
|
||||||
return { severity: 'error', message: messages.nonStandardText }
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -4247,14 +4247,11 @@
|
|||||||
"project.settings.tags.upload-version-first.heading": {
|
"project.settings.tags.upload-version-first.heading": {
|
||||||
"message": "Upload versions before adding tags"
|
"message": "Upload versions before adding tags"
|
||||||
},
|
},
|
||||||
"project.text-validation.non-standard-text": {
|
"project.slug-suggestions.label": {
|
||||||
"message": "Non-standard text characters are not allowed."
|
"message": "Suggestions:"
|
||||||
},
|
},
|
||||||
"project.text-validation.profanity": {
|
"project.text-validation.resolve-issues-to-save": {
|
||||||
"message": "Profanity is not allowed."
|
"message": "Resolve the issues from your edits to save."
|
||||||
},
|
|
||||||
"project.text-validation.slur": {
|
|
||||||
"message": "Slurs are not allowed."
|
|
||||||
},
|
},
|
||||||
"project.versions.copy-id-option": {
|
"project.versions.copy-id-option": {
|
||||||
"message": "Copy ID"
|
"message": "Copy ID"
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
:maxlength="64"
|
:maxlength="64"
|
||||||
placeholder="Enter title..."
|
placeholder="Enter title..."
|
||||||
/>
|
/>
|
||||||
|
<ValidationMessage :check="galleryTitleValidation" />
|
||||||
<label for="gallery-image-desc">
|
<label for="gallery-image-desc">
|
||||||
<span class="label__title">Description</span>
|
<span class="label__title">Description</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -53,6 +54,7 @@
|
|||||||
:maxlength="255"
|
:maxlength="255"
|
||||||
placeholder="Enter description..."
|
placeholder="Enter description..."
|
||||||
/>
|
/>
|
||||||
|
<ValidationMessage :check="galleryDescriptionValidation" />
|
||||||
<label for="gallery-image-ordering">
|
<label for="gallery-image-ordering">
|
||||||
<span class="label__title">Order Index</span>
|
<span class="label__title">Order Index</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -90,7 +92,7 @@
|
|||||||
v-if="editIndex === -1"
|
v-if="editIndex === -1"
|
||||||
type="colored"
|
type="colored"
|
||||||
color="brand"
|
color="brand"
|
||||||
:disabled="shouldPreventActions"
|
:disabled="shouldPreventActions || galleryFieldsInvalid"
|
||||||
@click="createGalleryItem"
|
@click="createGalleryItem"
|
||||||
>
|
>
|
||||||
<PlusIcon aria-hidden="true" />
|
<PlusIcon aria-hidden="true" />
|
||||||
@@ -100,7 +102,7 @@
|
|||||||
v-else
|
v-else
|
||||||
type="colored"
|
type="colored"
|
||||||
color="brand"
|
color="brand"
|
||||||
:disabled="shouldPreventActions"
|
:disabled="shouldPreventActions || galleryFieldsInvalid"
|
||||||
@click="editGalleryItem"
|
@click="editGalleryItem"
|
||||||
>
|
>
|
||||||
<SaveIcon aria-hidden="true" />
|
<SaveIcon aria-hidden="true" />
|
||||||
@@ -296,6 +298,7 @@ import {
|
|||||||
UploadIcon,
|
UploadIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
|
import { validateProjectText } from '@modrinth/moderation'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
ButtonLink,
|
ButtonLink,
|
||||||
@@ -312,6 +315,7 @@ import {
|
|||||||
import { useEventListener } from '@vueuse/core'
|
import { useEventListener } from '@vueuse/core'
|
||||||
|
|
||||||
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
|
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
|
||||||
|
import ValidationMessage from '~/components/ValidationMessage.vue'
|
||||||
import { fileDeclaresAi } from '~/helpers/c2pa'
|
import { fileDeclaresAi } from '~/helpers/c2pa'
|
||||||
import { isPermission } from '~/utils/permissions.ts'
|
import { isPermission } from '~/utils/permissions.ts'
|
||||||
|
|
||||||
@@ -379,6 +383,11 @@ const previewImage = ref<string | null>(null)
|
|||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
const shouldPreventActions = ref(false)
|
const shouldPreventActions = ref(false)
|
||||||
|
const galleryTitleValidation = computed(() => validateProjectText(editTitle.value))
|
||||||
|
const galleryDescriptionValidation = computed(() => validateProjectText(editDescription.value))
|
||||||
|
const galleryFieldsInvalid = computed(
|
||||||
|
() => !!galleryTitleValidation.value || !!galleryDescriptionValidation.value,
|
||||||
|
)
|
||||||
|
|
||||||
// Constant for accepted file types
|
// Constant for accepted file types
|
||||||
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
|
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
|
||||||
@@ -479,6 +488,7 @@ function showPreviewImage() {
|
|||||||
|
|
||||||
// CRUD operations
|
// CRUD operations
|
||||||
async function createGalleryItem() {
|
async function createGalleryItem() {
|
||||||
|
if (galleryFieldsInvalid.value) return
|
||||||
shouldPreventActions.value = true
|
shouldPreventActions.value = true
|
||||||
startLoading()
|
startLoading()
|
||||||
|
|
||||||
@@ -499,6 +509,7 @@ async function createGalleryItem() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function editGalleryItem() {
|
async function editGalleryItem() {
|
||||||
|
if (galleryFieldsInvalid.value) return
|
||||||
shouldPreventActions.value = true
|
shouldPreventActions.value = true
|
||||||
startLoading()
|
startLoading()
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,11 @@
|
|||||||
:modified="current"
|
:modified="current"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
:can-save="canSave"
|
:can-save="canSave"
|
||||||
|
:save-disabled-reason="
|
||||||
|
hasPermission && hasValidationIssues
|
||||||
|
? projectTextValidationMessages.resolveIssuesToSave
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
@reset="reset"
|
@reset="reset"
|
||||||
@save="save"
|
@save="save"
|
||||||
/>
|
/>
|
||||||
@@ -56,7 +61,10 @@ import { computed, useTemplateRef } from 'vue'
|
|||||||
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
|
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
|
||||||
import ValidationMessage from '~/components/ValidationMessage.vue'
|
import ValidationMessage from '~/components/ValidationMessage.vue'
|
||||||
import { useImageUpload } from '~/composables/image-upload.ts'
|
import { useImageUpload } from '~/composables/image-upload.ts'
|
||||||
import { validateProjectText } from '~/composables/project-text-validation'
|
import {
|
||||||
|
projectTextValidationMessages,
|
||||||
|
useProjectDescriptionValidation,
|
||||||
|
} from '~/composables/project-field-validation'
|
||||||
import { fileDeclaresAi } from '~/helpers/c2pa'
|
import { fileDeclaresAi } from '~/helpers/c2pa'
|
||||||
|
|
||||||
const { projectV2: project, currentMember, patchProject } = injectProjectPageContext()
|
const { projectV2: project, currentMember, patchProject } = injectProjectPageContext()
|
||||||
@@ -86,8 +94,12 @@ const hasPermission = computed(
|
|||||||
(currentMember.value.permissions & TeamMemberPermission.EDIT_BODY) ===
|
(currentMember.value.permissions & TeamMemberPermission.EDIT_BODY) ===
|
||||||
TeamMemberPermission.EDIT_BODY,
|
TeamMemberPermission.EDIT_BODY,
|
||||||
)
|
)
|
||||||
const descriptionValidation = computed(() => validateProjectText(current.value.description))
|
const { pending: descriptionLinksPending, validation: descriptionValidation } =
|
||||||
const canSave = computed(() => hasPermission.value && !descriptionValidation.value)
|
useProjectDescriptionValidation(() => current.value.description)
|
||||||
|
const hasValidationIssues = computed(() => descriptionValidation.value?.severity === 'error')
|
||||||
|
const canSave = computed(
|
||||||
|
() => hasPermission.value && !hasValidationIssues.value && !descriptionLinksPending.value,
|
||||||
|
)
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!canSave.value) return
|
if (!canSave.value) return
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
:maxlength="64"
|
:maxlength="64"
|
||||||
placeholder="Enter title..."
|
placeholder="Enter title..."
|
||||||
/>
|
/>
|
||||||
|
<ValidationMessage :check="galleryTitleValidation" />
|
||||||
<label for="gallery-image-desc">
|
<label for="gallery-image-desc">
|
||||||
<span class="label__title">Description</span>
|
<span class="label__title">Description</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -53,6 +54,7 @@
|
|||||||
:maxlength="255"
|
:maxlength="255"
|
||||||
placeholder="Enter description..."
|
placeholder="Enter description..."
|
||||||
/>
|
/>
|
||||||
|
<ValidationMessage :check="galleryDescriptionValidation" />
|
||||||
<label for="gallery-image-ordering">
|
<label for="gallery-image-ordering">
|
||||||
<span class="label__title">Order Index</span>
|
<span class="label__title">Order Index</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -90,7 +92,7 @@
|
|||||||
v-if="editIndex === -1"
|
v-if="editIndex === -1"
|
||||||
type="colored"
|
type="colored"
|
||||||
color="brand"
|
color="brand"
|
||||||
:disabled="shouldPreventActions"
|
:disabled="shouldPreventActions || galleryFieldsInvalid"
|
||||||
@click="createGalleryItem"
|
@click="createGalleryItem"
|
||||||
>
|
>
|
||||||
<PlusIcon aria-hidden="true" />
|
<PlusIcon aria-hidden="true" />
|
||||||
@@ -100,7 +102,7 @@
|
|||||||
v-else
|
v-else
|
||||||
type="colored"
|
type="colored"
|
||||||
color="brand"
|
color="brand"
|
||||||
:disabled="shouldPreventActions"
|
:disabled="shouldPreventActions || galleryFieldsInvalid"
|
||||||
@click="editGalleryItem"
|
@click="editGalleryItem"
|
||||||
>
|
>
|
||||||
<SaveIcon aria-hidden="true" />
|
<SaveIcon aria-hidden="true" />
|
||||||
@@ -286,6 +288,7 @@ import {
|
|||||||
UploadIcon,
|
UploadIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
|
import { validateProjectText } from '@modrinth/moderation'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
ButtonLink,
|
ButtonLink,
|
||||||
@@ -302,6 +305,7 @@ import {
|
|||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
|
|
||||||
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
|
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
|
||||||
|
import ValidationMessage from '~/components/ValidationMessage.vue'
|
||||||
import { fileDeclaresAi } from '~/helpers/c2pa'
|
import { fileDeclaresAi } from '~/helpers/c2pa'
|
||||||
import { isPermission } from '~/utils/permissions.ts'
|
import { isPermission } from '~/utils/permissions.ts'
|
||||||
|
|
||||||
@@ -339,6 +343,11 @@ const editOrder = ref(null)
|
|||||||
const editFile = ref(null)
|
const editFile = ref(null)
|
||||||
const previewImage = ref(null)
|
const previewImage = ref(null)
|
||||||
const shouldPreventActions = ref(false)
|
const shouldPreventActions = ref(false)
|
||||||
|
const galleryTitleValidation = computed(() => validateProjectText(editTitle.value))
|
||||||
|
const galleryDescriptionValidation = computed(() => validateProjectText(editDescription.value))
|
||||||
|
const galleryFieldsInvalid = computed(
|
||||||
|
() => !!galleryTitleValidation.value || !!galleryDescriptionValidation.value,
|
||||||
|
)
|
||||||
|
|
||||||
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
|
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
|
||||||
const acceptFileTypes = 'image/png,image/jpeg,image/gif,image/webp,.png,.jpeg,.gif,.webp'
|
const acceptFileTypes = 'image/png,image/jpeg,image/gif,image/webp,.png,.jpeg,.gif,.webp'
|
||||||
@@ -419,6 +428,7 @@ const showPreviewImage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createGalleryItem = async () => {
|
const createGalleryItem = async () => {
|
||||||
|
if (galleryFieldsInvalid.value) return
|
||||||
shouldPreventActions.value = true
|
shouldPreventActions.value = true
|
||||||
|
|
||||||
const success = await createGalleryItemMutation(
|
const success = await createGalleryItemMutation(
|
||||||
@@ -437,6 +447,7 @@ const createGalleryItem = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const editGalleryItem = async () => {
|
const editGalleryItem = async () => {
|
||||||
|
if (galleryFieldsInvalid.value) return
|
||||||
shouldPreventActions.value = true
|
shouldPreventActions.value = true
|
||||||
|
|
||||||
const success = await editGalleryItemMutation(
|
const success = await editGalleryItemMutation(
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ import {
|
|||||||
|
|
||||||
import ValidationMessage from '~/components/ValidationMessage.vue'
|
import ValidationMessage from '~/components/ValidationMessage.vue'
|
||||||
import SlugSuggestions from '~/components/ui/SlugSuggestions.vue'
|
import SlugSuggestions from '~/components/ui/SlugSuggestions.vue'
|
||||||
import { validateProjectText } from '~/composables/project-text-validation'
|
import {
|
||||||
|
useProjectSummaryValidation,
|
||||||
|
useProjectTitleValidation,
|
||||||
|
} from '~/composables/project-field-validation'
|
||||||
import {
|
import {
|
||||||
useProjectSlugSuggestions,
|
useProjectSlugSuggestions,
|
||||||
useSlugSuggestionVisibility,
|
useSlugSuggestionVisibility,
|
||||||
@@ -53,8 +56,11 @@ const {
|
|||||||
|
|
||||||
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
|
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
|
||||||
|
|
||||||
const titleValidation = computed(() => validateProjectText(current.value.title))
|
const titleValidation = useProjectTitleValidation(() => current.value.title)
|
||||||
const taglineValidation = computed(() => validateProjectText(current.value.tagline))
|
const taglineValidation = useProjectSummaryValidation(
|
||||||
|
() => current.value.tagline,
|
||||||
|
() => current.value.title,
|
||||||
|
)
|
||||||
const canSave = computed(() => !titleValidation.value && !taglineValidation.value)
|
const canSave = computed(() => !titleValidation.value && !taglineValidation.value)
|
||||||
const {
|
const {
|
||||||
onFocusIn: onSlugSuggestionFocusIn,
|
onFocusIn: onSlugSuggestionFocusIn,
|
||||||
|
|||||||
@@ -294,6 +294,11 @@
|
|||||||
:modified="modified"
|
:modified="modified"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
:can-save="canSave"
|
:can-save="canSave"
|
||||||
|
:save-disabled-reason="
|
||||||
|
hasPermission && hasValidationIssues
|
||||||
|
? projectTextValidationMessages.resolveIssuesToSave
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
@reset="resetChanges"
|
@reset="resetChanges"
|
||||||
@save="handleSave"
|
@save="handleSave"
|
||||||
/>
|
/>
|
||||||
@@ -334,7 +339,11 @@ import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
|
|||||||
import SlugSuggestions from '~/components/ui/SlugSuggestions.vue'
|
import SlugSuggestions from '~/components/ui/SlugSuggestions.vue'
|
||||||
import ValidationMessage from '~/components/ValidationMessage.vue'
|
import ValidationMessage from '~/components/ValidationMessage.vue'
|
||||||
import { useAuth } from '~/composables/auth.js'
|
import { useAuth } from '~/composables/auth.js'
|
||||||
import { validateProjectText } from '~/composables/project-text-validation'
|
import {
|
||||||
|
projectTextValidationMessages,
|
||||||
|
useProjectSummaryValidation,
|
||||||
|
useProjectTitleValidation,
|
||||||
|
} from '~/composables/project-field-validation'
|
||||||
import {
|
import {
|
||||||
useProjectSlugSuggestions,
|
useProjectSlugSuggestions,
|
||||||
useSlugSuggestionVisibility,
|
useSlugSuggestionVisibility,
|
||||||
@@ -424,11 +433,10 @@ const hasPermission = computed(() => {
|
|||||||
return ((currentMember.value?.permissions ?? 0) & EDIT_DETAILS) === EDIT_DETAILS
|
return ((currentMember.value?.permissions ?? 0) & EDIT_DETAILS) === EDIT_DETAILS
|
||||||
})
|
})
|
||||||
|
|
||||||
const nameValidation = computed(() => validateProjectText(name.value))
|
const nameValidation = useProjectTitleValidation(name)
|
||||||
const summaryValidation = computed(() => validateProjectText(summary.value))
|
const summaryValidation = useProjectSummaryValidation(summary, name)
|
||||||
const canSave = computed(
|
const hasValidationIssues = computed(() => !!nameValidation.value || !!summaryValidation.value)
|
||||||
() => hasPermission.value && !nameValidation.value && !summaryValidation.value,
|
const canSave = computed(() => hasPermission.value && !hasValidationIssues.value)
|
||||||
)
|
|
||||||
|
|
||||||
const monetizationToggleDisabled = computed(() => !hasPermission.value || isForceDemonetized.value)
|
const monetizationToggleDisabled = computed(() => !hasPermission.value || isForceDemonetized.value)
|
||||||
|
|
||||||
|
|||||||
@@ -15,12 +15,14 @@
|
|||||||
"@modrinth/assets": "workspace:*",
|
"@modrinth/assets": "workspace:*",
|
||||||
"@modrinth/utils": "workspace:*",
|
"@modrinth/utils": "workspace:*",
|
||||||
"@modrinth/api-client": "workspace:*",
|
"@modrinth/api-client": "workspace:*",
|
||||||
|
"linkify-it": "^5.0.0",
|
||||||
"vue": "^3.5.13"
|
"vue": "^3.5.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@formatjs/cli": "^6.2.12",
|
"@formatjs/cli": "^6.2.12",
|
||||||
"@modrinth/tooling-config": "workspace:*",
|
"@modrinth/tooling-config": "workspace:*",
|
||||||
"@modrinth/ui": "workspace:*",
|
"@modrinth/ui": "workspace:*",
|
||||||
|
"@types/linkify-it": "^5.0.0",
|
||||||
"typescript": "^5.4.5"
|
"typescript": "^5.4.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,3 +22,4 @@ export * from './utils'
|
|||||||
export * from './validators/link-checks'
|
export * from './validators/link-checks'
|
||||||
export * from './validators/non-standard-text'
|
export * from './validators/non-standard-text'
|
||||||
export * from './validators/profanity'
|
export * from './validators/profanity'
|
||||||
|
export * from './validators/project-fields'
|
||||||
|
|||||||
@@ -153,7 +153,7 @@
|
|||||||
"defaultMessage": "The wiki is disabled on this repository."
|
"defaultMessage": "The wiki is disabled on this repository."
|
||||||
},
|
},
|
||||||
"nags.link.invalid-url": {
|
"nags.link.invalid-url": {
|
||||||
"defaultMessage": "This is not a valid URL."
|
"defaultMessage": "There's an invalid URL in the description."
|
||||||
},
|
},
|
||||||
"nags.link.license.url-mismatch": {
|
"nags.link.license.url-mismatch": {
|
||||||
"defaultMessage": "This link points to the {detected} license, but your project is set to {selected}."
|
"defaultMessage": "This link points to the {detected} license, but your project is set to {selected}."
|
||||||
@@ -343,5 +343,26 @@
|
|||||||
},
|
},
|
||||||
"nags.visit-links-settings.title": {
|
"nags.visit-links-settings.title": {
|
||||||
"defaultMessage": "Visit links settings"
|
"defaultMessage": "Visit links settings"
|
||||||
|
},
|
||||||
|
"project.text-validation.non-standard-text": {
|
||||||
|
"defaultMessage": "Non-standard text characters are not allowed."
|
||||||
|
},
|
||||||
|
"project.text-validation.profanity": {
|
||||||
|
"defaultMessage": "Profanity is not allowed."
|
||||||
|
},
|
||||||
|
"project.text-validation.slur": {
|
||||||
|
"defaultMessage": "Slurs are not allowed."
|
||||||
|
},
|
||||||
|
"project.text-validation.summary-link": {
|
||||||
|
"defaultMessage": "Links are not allowed in project summaries."
|
||||||
|
},
|
||||||
|
"project.text-validation.summary-matches-title": {
|
||||||
|
"defaultMessage": "A project summary cannot be the same as its title."
|
||||||
|
},
|
||||||
|
"project.text-validation.title-game-version": {
|
||||||
|
"defaultMessage": "Project titles cannot include the Minecraft version “{value}”."
|
||||||
|
},
|
||||||
|
"project.text-validation.title-loader": {
|
||||||
|
"defaultMessage": "Project titles cannot include the loader “{value}”."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ function defineMessages<T extends Record<string, MessageDescriptor>>(descriptors
|
|||||||
export interface LinkCheckContext {
|
export interface LinkCheckContext {
|
||||||
url: string | undefined
|
url: string | undefined
|
||||||
field: string
|
field: string
|
||||||
|
generalContent?: boolean
|
||||||
|
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
@@ -226,7 +227,10 @@ async function matchNode(
|
|||||||
(child) => !isAsyncMatcher(child.when) && !child.isFallback,
|
(child) => !isAsyncMatcher(child.when) && !child.isFallback,
|
||||||
)
|
)
|
||||||
const asyncChildren = node.childNodes.filter(
|
const asyncChildren = node.childNodes.filter(
|
||||||
(child) => isAsyncMatcher(child.when) && !child.isFallback,
|
(child) =>
|
||||||
|
isAsyncMatcher(child.when) &&
|
||||||
|
!child.isFallback &&
|
||||||
|
!(context.generalContent && hasFieldSpecificDescendant(child)),
|
||||||
)
|
)
|
||||||
const fallbackChildren = node.childNodes.filter((child) => child.isFallback)
|
const fallbackChildren = node.childNodes.filter((child) => child.isFallback)
|
||||||
let expectedChild: LinkCheckNode | undefined
|
let expectedChild: LinkCheckNode | undefined
|
||||||
@@ -272,7 +276,7 @@ const coreMessages = defineMessages({
|
|||||||
//TODO: we should probably just let you not provide https but backend currently requires it
|
//TODO: we should probably just let you not provide https but backend currently requires it
|
||||||
const invalidUrlMessage = defineMessage({
|
const invalidUrlMessage = defineMessage({
|
||||||
id: 'nags.link.invalid-url',
|
id: 'nags.link.invalid-url',
|
||||||
defaultMessage: 'This is not a valid URL.',
|
defaultMessage: "There's an invalid URL in the description.",
|
||||||
})
|
})
|
||||||
|
|
||||||
function validUrlPrefix(remaining: string): number | null {
|
function validUrlPrefix(remaining: string): number | null {
|
||||||
@@ -314,6 +318,13 @@ function cacheKey(context: LinkCheckContext): string {
|
|||||||
return JSON.stringify(context)
|
return JSON.stringify(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasFieldSpecificDescendant(node: LinkCheckNode): boolean {
|
||||||
|
return (
|
||||||
|
(node.forMatchers?.length ?? 0) > 0 ||
|
||||||
|
(node.childNodes?.some((child) => hasFieldSpecificDescendant(child)) ?? false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async function checkLink(context: LinkCheckContext) {
|
async function checkLink(context: LinkCheckContext) {
|
||||||
const url = context.url
|
const url = context.url
|
||||||
if (!url) return
|
if (!url) return
|
||||||
@@ -327,6 +338,10 @@ async function checkLink(context: LinkCheckContext) {
|
|||||||
|
|
||||||
const found = await matchNode(rootNode, normalizedUrl, context, true)
|
const found = await matchNode(rootNode, normalizedUrl, context, true)
|
||||||
if (!found) {
|
if (!found) {
|
||||||
|
if (context.generalContent && validUrlPrefix(normalizedUrl) !== null) {
|
||||||
|
cache.set(key, valid)
|
||||||
|
return
|
||||||
|
}
|
||||||
cache.delete(key)
|
cache.delete(key)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -336,6 +351,11 @@ async function checkLink(context: LinkCheckContext) {
|
|||||||
const applies = isLeaf && matched.forMatchers?.some((matcher) => matchesField(matcher, context))
|
const applies = isLeaf && matched.forMatchers?.some((matcher) => matchesField(matcher, context))
|
||||||
|
|
||||||
if (!applies) {
|
if (!applies) {
|
||||||
|
if (context.generalContent && hasFieldSpecificDescendant(matched)) {
|
||||||
|
cache.set(key, valid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const build = matched.unrecognizedSeverity === 'warn' ? warn : error
|
const build = matched.unrecognizedSeverity === 'warn' ? warn : error
|
||||||
|
|
||||||
if (matched.unrecognizedMessage && isLeaf) {
|
if (matched.unrecognizedMessage && isLeaf) {
|
||||||
|
|||||||
@@ -36,6 +36,37 @@ test('rejects a recognized link used in the wrong field', async () => {
|
|||||||
assert.equal(getLinkCheckState(context)?.message?.id, 'nags.link.wrong-field')
|
assert.equal(getLinkCheckState(context)?.message?.id, 'nags.link.wrong-field')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('allows structured link types in general content', async () => {
|
||||||
|
const context = {
|
||||||
|
field: 'description',
|
||||||
|
url: 'https://github.com/modrinth/code',
|
||||||
|
generalContent: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkLink(context)
|
||||||
|
|
||||||
|
assert.equal(getLinkCheckState(context)?.severity, 'valid')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('allows unrecognized valid links but keeps global restrictions in general content', async () => {
|
||||||
|
const allowed = {
|
||||||
|
field: 'description',
|
||||||
|
url: 'https://docs.example.dev/project',
|
||||||
|
generalContent: true,
|
||||||
|
}
|
||||||
|
const blocked = {
|
||||||
|
field: 'description',
|
||||||
|
url: 'https://bit.ly/project',
|
||||||
|
generalContent: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkLink(allowed)
|
||||||
|
await checkLink(blocked)
|
||||||
|
|
||||||
|
assert.equal(getLinkCheckState(allowed)?.severity, 'valid')
|
||||||
|
assert.equal(getLinkCheckState(blocked)?.severity, 'error')
|
||||||
|
})
|
||||||
|
|
||||||
test('compares recognized license URLs with the selected license', async () => {
|
test('compares recognized license URLs with the selected license', async () => {
|
||||||
const matching = {
|
const matching = {
|
||||||
field: 'license',
|
field: 'license',
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ export interface NonStandardTextOptions {
|
|||||||
|
|
||||||
export const DEFAULT_MAX_COMBINING_MARKS_PER_CHARACTER = 2
|
export const DEFAULT_MAX_COMBINING_MARKS_PER_CHARACTER = 2
|
||||||
|
|
||||||
|
export function getNonStandardTextRatio(text: string, result: NonStandardTextResult): number {
|
||||||
|
const characterCount = Array.from(text).length
|
||||||
|
if (characterCount === 0) return 0
|
||||||
|
|
||||||
|
const nonStandardCharacterCount = new Set(result.issues.map(({ index }) => index)).size
|
||||||
|
return nonStandardCharacterCount / characterCount
|
||||||
|
}
|
||||||
|
|
||||||
const FANCY_RANGES: ReadonlyArray<readonly [number, number]> = [
|
const FANCY_RANGES: ReadonlyArray<readonly [number, number]> = [
|
||||||
[0x02b0, 0x02ff],
|
[0x02b0, 0x02ff],
|
||||||
[0x1d400, 0x1d7ff],
|
[0x1d400, 0x1d7ff],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
|
||||||
import { validateNonStandardText } from './index.ts'
|
import { getNonStandardTextRatio, validateNonStandardText } from './index.ts'
|
||||||
|
|
||||||
test('accepts ordinary multilingual text and punctuation', () => {
|
test('accepts ordinary multilingual text and punctuation', () => {
|
||||||
const result = validateNonStandardText(
|
const result = validateNonStandardText(
|
||||||
@@ -119,3 +119,18 @@ test('reports multiple issue categories in source order', () => {
|
|||||||
['fancy', 'invisible', 'control'],
|
['fancy', 'invisible', 'control'],
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('calculates the ratio of non-standard Unicode characters', () => {
|
||||||
|
const belowFivePercent = '𝐀'.concat('a'.repeat(20))
|
||||||
|
const exactlyFivePercent = '𝐀'.concat('a'.repeat(19))
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
getNonStandardTextRatio(belowFivePercent, validateNonStandardText(belowFivePercent)),
|
||||||
|
1 / 21,
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
getNonStandardTextRatio(exactlyFivePercent, validateNonStandardText(exactlyFivePercent)),
|
||||||
|
0.05,
|
||||||
|
)
|
||||||
|
assert.equal(getNonStandardTextRatio('', validateNonStandardText('')), 0)
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import LinkifyIt from 'linkify-it'
|
||||||
|
|
||||||
|
import { getNonStandardTextRatio, validateNonStandardText } from '../non-standard-text/index.ts'
|
||||||
|
import { validateProfanity } from '../profanity/index.ts'
|
||||||
|
|
||||||
|
export interface ProjectFieldMessageDescriptor {
|
||||||
|
id: string
|
||||||
|
defaultMessage?: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function defineMessages<T extends Record<string, ProjectFieldMessageDescriptor>>(descriptors: T): T {
|
||||||
|
return descriptors
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectTextValidationResult {
|
||||||
|
severity: 'error'
|
||||||
|
message: ProjectFieldMessageDescriptor
|
||||||
|
values?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectTextValidationOptions {
|
||||||
|
nonStandardTextFailureThreshold?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DESCRIPTION_NON_STANDARD_TEXT_FAILURE_THRESHOLD = 0.05
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
slur: {
|
||||||
|
id: 'project.text-validation.slur',
|
||||||
|
defaultMessage: 'Slurs are not allowed.',
|
||||||
|
},
|
||||||
|
profanity: {
|
||||||
|
id: 'project.text-validation.profanity',
|
||||||
|
defaultMessage: 'Profanity is not allowed.',
|
||||||
|
},
|
||||||
|
nonStandardText: {
|
||||||
|
id: 'project.text-validation.non-standard-text',
|
||||||
|
defaultMessage: 'Non-standard text characters are not allowed.',
|
||||||
|
},
|
||||||
|
titleGameVersion: {
|
||||||
|
id: 'project.text-validation.title-game-version',
|
||||||
|
defaultMessage: 'Project titles cannot include the Minecraft version “{value}”.',
|
||||||
|
},
|
||||||
|
titleLoader: {
|
||||||
|
id: 'project.text-validation.title-loader',
|
||||||
|
defaultMessage: 'Project titles cannot include the loader “{value}”.',
|
||||||
|
},
|
||||||
|
summaryLink: {
|
||||||
|
id: 'project.text-validation.summary-link',
|
||||||
|
defaultMessage: 'Links are not allowed in project summaries.',
|
||||||
|
},
|
||||||
|
summaryMatchesTitle: {
|
||||||
|
id: 'project.text-validation.summary-matches-title',
|
||||||
|
defaultMessage: 'A project summary cannot be the same as its title.',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const titleMetadataMessages = {
|
||||||
|
'game-version': messages.titleGameVersion,
|
||||||
|
loader: messages.titleLoader,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectTitleMetadataKind = 'game-version' | 'loader'
|
||||||
|
|
||||||
|
export interface ProjectTitleMetadata {
|
||||||
|
gameVersions: readonly string[]
|
||||||
|
loaders: readonly string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectTitleMetadataMatch {
|
||||||
|
kind: ProjectTitleMetadataKind
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkify = new LinkifyIt({
|
||||||
|
fuzzyEmail: false,
|
||||||
|
fuzzyIP: true,
|
||||||
|
fuzzyLink: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
function normalizeForSearch(value: string) {
|
||||||
|
return value.normalize('NFC').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findProjectTitleMetadata(
|
||||||
|
title: string,
|
||||||
|
metadata: ProjectTitleMetadata,
|
||||||
|
): ProjectTitleMetadataMatch | null {
|
||||||
|
const normalizedTitle = normalizeForSearch(title)
|
||||||
|
const groups: ReadonlyArray<readonly [ProjectTitleMetadataKind, readonly string[]]> = [
|
||||||
|
['game-version', metadata.gameVersions],
|
||||||
|
['loader', metadata.loaders],
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const [kind, values] of groups) {
|
||||||
|
for (const value of values) {
|
||||||
|
const normalizedValue = normalizeForSearch(value.trim())
|
||||||
|
if (normalizedValue && normalizedTitle.includes(normalizedValue)) {
|
||||||
|
return { kind, value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeProjectFieldText(value: string) {
|
||||||
|
return value.trim().normalize('NFC')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectSummaryMatchesTitle(summary: string, title: string) {
|
||||||
|
const normalizedSummary = normalizeProjectFieldText(summary)
|
||||||
|
const normalizedTitle = normalizeProjectFieldText(title)
|
||||||
|
|
||||||
|
return normalizedSummary.length > 0 && normalizedSummary === normalizedTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractProjectLinks(text: string) {
|
||||||
|
const matches = linkify.match(text) ?? []
|
||||||
|
return [...new Set(matches.map((match) => match.url))]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function containsProjectLinkOrIp(text: string) {
|
||||||
|
return linkify.test(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateProjectText(
|
||||||
|
text: string | null | undefined,
|
||||||
|
options: ProjectTextValidationOptions = {},
|
||||||
|
): ProjectTextValidationResult | null {
|
||||||
|
if (!text) return null
|
||||||
|
|
||||||
|
const profanity = validateProfanity(text)
|
||||||
|
if (profanity.slurCount > 0) {
|
||||||
|
return { severity: 'error', message: messages.slur }
|
||||||
|
}
|
||||||
|
if (profanity.profanityCount > 0) {
|
||||||
|
return { severity: 'error', message: messages.profanity }
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonStandardText = validateNonStandardText(text)
|
||||||
|
const nonStandardTextFailureThreshold = options.nonStandardTextFailureThreshold ?? 0
|
||||||
|
if (
|
||||||
|
!nonStandardText.valid &&
|
||||||
|
getNonStandardTextRatio(text, nonStandardText) >= nonStandardTextFailureThreshold
|
||||||
|
) {
|
||||||
|
return { severity: 'error', message: messages.nonStandardText }
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateProjectTitle(
|
||||||
|
text: string | null | undefined,
|
||||||
|
metadata: ProjectTitleMetadata,
|
||||||
|
): ProjectTextValidationResult | null {
|
||||||
|
const textValidation = validateProjectText(text)
|
||||||
|
if (textValidation || !text) return textValidation
|
||||||
|
|
||||||
|
const match = findProjectTitleMetadata(text, metadata)
|
||||||
|
if (!match) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
severity: 'error',
|
||||||
|
message: titleMetadataMessages[match.kind],
|
||||||
|
values: { value: match.value },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateProjectSummary(
|
||||||
|
summary: string | null | undefined,
|
||||||
|
title: string | null | undefined,
|
||||||
|
): ProjectTextValidationResult | null {
|
||||||
|
const textValidation = validateProjectText(summary)
|
||||||
|
if (textValidation || !summary) return textValidation
|
||||||
|
|
||||||
|
if (containsProjectLinkOrIp(summary)) {
|
||||||
|
return { severity: 'error', message: messages.summaryLink }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title && projectSummaryMatchesTitle(summary, title)) {
|
||||||
|
return { severity: 'error', message: messages.summaryMatchesTitle }
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateProjectDescription(
|
||||||
|
description: string | null | undefined,
|
||||||
|
): ProjectTextValidationResult | null {
|
||||||
|
return validateProjectText(description, {
|
||||||
|
nonStandardTextFailureThreshold: DESCRIPTION_NON_STANDARD_TEXT_FAILURE_THRESHOLD,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
containsProjectLinkOrIp,
|
||||||
|
extractProjectLinks,
|
||||||
|
findProjectTitleMetadata,
|
||||||
|
projectSummaryMatchesTitle,
|
||||||
|
validateProjectDescription,
|
||||||
|
validateProjectSummary,
|
||||||
|
validateProjectText,
|
||||||
|
validateProjectTitle,
|
||||||
|
} from './index.ts'
|
||||||
|
|
||||||
|
const metadata = {
|
||||||
|
gameVersions: ['1.21.1'],
|
||||||
|
loaders: ['fabric'],
|
||||||
|
}
|
||||||
|
|
||||||
|
test('finds game versions and loaders in project titles', () => {
|
||||||
|
assert.deepEqual(findProjectTitleMetadata('Tools for 1.21.1', metadata), {
|
||||||
|
kind: 'game-version',
|
||||||
|
value: '1.21.1',
|
||||||
|
})
|
||||||
|
assert.deepEqual(findProjectTitleMetadata('FABRIC Tools', metadata), {
|
||||||
|
kind: 'loader',
|
||||||
|
value: 'fabric',
|
||||||
|
})
|
||||||
|
assert.equal(findProjectTitleMetadata('Magical Tools', metadata), null)
|
||||||
|
assert.equal(findProjectTitleMetadata('Ordinary Tools', metadata), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('compares summaries and titles after trimming and Unicode normalization', () => {
|
||||||
|
assert.equal(projectSummaryMatchesTitle(' Caf\u00e9 ', 'Cafe\u0301'), true)
|
||||||
|
assert.equal(projectSummaryMatchesTitle('Project summary', 'Project title'), false)
|
||||||
|
assert.equal(projectSummaryMatchesTitle('', ''), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detects links and IP addresses but not email addresses or game versions', () => {
|
||||||
|
assert.equal(containsProjectLinkOrIp('Visit https://modrinth.com'), true)
|
||||||
|
assert.equal(containsProjectLinkOrIp('Visit modrinth.com'), true)
|
||||||
|
assert.equal(containsProjectLinkOrIp('Join 127.0.0.1:25565'), true)
|
||||||
|
assert.equal(containsProjectLinkOrIp('Supports Minecraft 1.21.1'), false)
|
||||||
|
assert.equal(containsProjectLinkOrIp('Contact hello@example.com'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('extracts and deduplicates normalized links', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
extractProjectLinks(
|
||||||
|
'Visit [Modrinth](https://modrinth.com) and example.net twice: example.net',
|
||||||
|
),
|
||||||
|
['https://modrinth.com', 'http://example.net'],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('validates shared project text', () => {
|
||||||
|
assert.equal(validateProjectText('An ordinary project'), null)
|
||||||
|
assert.equal(
|
||||||
|
validateProjectText('This project is shit')?.message.id,
|
||||||
|
'project.text-validation.profanity',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
validateProjectText('𝐅ancy project')?.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}”.',
|
||||||
|
},
|
||||||
|
values: { value: 'fabric' },
|
||||||
|
})
|
||||||
|
assert.equal(validateProjectTitle('Ordinary Tools', metadata), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('validates project summaries', () => {
|
||||||
|
assert.equal(
|
||||||
|
validateProjectSummary('Visit modrinth.com', 'Project title')?.message.id,
|
||||||
|
'project.text-validation.summary-link',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
validateProjectSummary(' Caf\u00e9 ', 'Cafe\u0301')?.message.id,
|
||||||
|
'project.text-validation.summary-matches-title',
|
||||||
|
)
|
||||||
|
assert.equal(validateProjectSummary('Project summary', 'Project title'), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
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,
|
||||||
|
'project.text-validation.non-standard-text',
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
{
|
{
|
||||||
"extends": "@modrinth/tooling-config/typescript/vue.json"
|
"extends": "@modrinth/tooling-config/typescript/vue.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowImportingTsExtensions": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+6
@@ -557,6 +557,9 @@ importers:
|
|||||||
'@modrinth/utils':
|
'@modrinth/utils':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../utils
|
version: link:../utils
|
||||||
|
linkify-it:
|
||||||
|
specifier: ^5.0.0
|
||||||
|
version: 5.0.0
|
||||||
vue:
|
vue:
|
||||||
specifier: ^3.5.13
|
specifier: ^3.5.13
|
||||||
version: 3.5.27(typescript@5.9.3)
|
version: 3.5.27(typescript@5.9.3)
|
||||||
@@ -570,6 +573,9 @@ importers:
|
|||||||
'@modrinth/ui':
|
'@modrinth/ui':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../ui
|
version: link:../ui
|
||||||
|
'@types/linkify-it':
|
||||||
|
specifier: ^5.0.0
|
||||||
|
version: 5.0.0
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.4.5
|
specifier: ^5.4.5
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|||||||
Reference in New Issue
Block a user