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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { TriangleAlertIcon, XCircleIcon } from '@modrinth/assets'
|
||||
import { useVIntl } from '@modrinth/ui'
|
||||
import { type MessageDescriptor, useVIntl } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
check: { type: Object, default: null },
|
||||
interface ValidationCheck {
|
||||
severity: 'valid' | 'warn' | 'error'
|
||||
message?: MessageDescriptor
|
||||
values?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{ check?: ValidationCheck | null }>(), {
|
||||
check: null,
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -161,7 +161,10 @@ import { computed, defineAsyncComponent, h } from 'vue'
|
||||
|
||||
import ValidationMessage from '~/components/ValidationMessage.vue'
|
||||
import SlugSuggestions from '~/components/ui/SlugSuggestions.vue'
|
||||
import { validateProjectText } from '~/composables/project-text-validation'
|
||||
import {
|
||||
useProjectSummaryValidation,
|
||||
useProjectTitleValidation,
|
||||
} from '~/composables/project-field-validation'
|
||||
import {
|
||||
useProjectSlugSuggestions,
|
||||
useSlugSuggestionVisibility,
|
||||
@@ -331,8 +334,8 @@ const visibilities = ref<VisibilityOption[]>([
|
||||
])
|
||||
const visibility = ref<VisibilityOption>(visibilities.value[0])
|
||||
|
||||
const nameValidation = computed(() => validateProjectText(name.value))
|
||||
const summaryValidation = computed(() => validateProjectText(description.value))
|
||||
const nameValidation = useProjectTitleValidation(name)
|
||||
const summaryValidation = useProjectSummaryValidation(description, name)
|
||||
|
||||
const disableCreate = computed(() => {
|
||||
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": {
|
||||
"message": "Upload versions before adding tags"
|
||||
},
|
||||
"project.text-validation.non-standard-text": {
|
||||
"message": "Non-standard text characters are not allowed."
|
||||
"project.slug-suggestions.label": {
|
||||
"message": "Suggestions:"
|
||||
},
|
||||
"project.text-validation.profanity": {
|
||||
"message": "Profanity is not allowed."
|
||||
},
|
||||
"project.text-validation.slur": {
|
||||
"message": "Slurs are not allowed."
|
||||
"project.text-validation.resolve-issues-to-save": {
|
||||
"message": "Resolve the issues from your edits to save."
|
||||
},
|
||||
"project.versions.copy-id-option": {
|
||||
"message": "Copy ID"
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
:maxlength="64"
|
||||
placeholder="Enter title..."
|
||||
/>
|
||||
<ValidationMessage :check="galleryTitleValidation" />
|
||||
<label for="gallery-image-desc">
|
||||
<span class="label__title">Description</span>
|
||||
</label>
|
||||
@@ -53,6 +54,7 @@
|
||||
:maxlength="255"
|
||||
placeholder="Enter description..."
|
||||
/>
|
||||
<ValidationMessage :check="galleryDescriptionValidation" />
|
||||
<label for="gallery-image-ordering">
|
||||
<span class="label__title">Order Index</span>
|
||||
</label>
|
||||
@@ -90,7 +92,7 @@
|
||||
v-if="editIndex === -1"
|
||||
type="colored"
|
||||
color="brand"
|
||||
:disabled="shouldPreventActions"
|
||||
:disabled="shouldPreventActions || galleryFieldsInvalid"
|
||||
@click="createGalleryItem"
|
||||
>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
@@ -100,7 +102,7 @@
|
||||
v-else
|
||||
type="colored"
|
||||
color="brand"
|
||||
:disabled="shouldPreventActions"
|
||||
:disabled="shouldPreventActions || galleryFieldsInvalid"
|
||||
@click="editGalleryItem"
|
||||
>
|
||||
<SaveIcon aria-hidden="true" />
|
||||
@@ -296,6 +298,7 @@ import {
|
||||
UploadIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { validateProjectText } from '@modrinth/moderation'
|
||||
import {
|
||||
Button,
|
||||
ButtonLink,
|
||||
@@ -312,6 +315,7 @@ import {
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
|
||||
import ValidationMessage from '~/components/ValidationMessage.vue'
|
||||
import { fileDeclaresAi } from '~/helpers/c2pa'
|
||||
import { isPermission } from '~/utils/permissions.ts'
|
||||
|
||||
@@ -379,6 +383,11 @@ const previewImage = ref<string | null>(null)
|
||||
|
||||
// UI state
|
||||
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
|
||||
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
|
||||
@@ -479,6 +488,7 @@ function showPreviewImage() {
|
||||
|
||||
// CRUD operations
|
||||
async function createGalleryItem() {
|
||||
if (galleryFieldsInvalid.value) return
|
||||
shouldPreventActions.value = true
|
||||
startLoading()
|
||||
|
||||
@@ -499,6 +509,7 @@ async function createGalleryItem() {
|
||||
}
|
||||
|
||||
async function editGalleryItem() {
|
||||
if (galleryFieldsInvalid.value) return
|
||||
shouldPreventActions.value = true
|
||||
startLoading()
|
||||
|
||||
|
||||
@@ -32,6 +32,11 @@
|
||||
:modified="current"
|
||||
:saving="saving"
|
||||
:can-save="canSave"
|
||||
:save-disabled-reason="
|
||||
hasPermission && hasValidationIssues
|
||||
? projectTextValidationMessages.resolveIssuesToSave
|
||||
: undefined
|
||||
"
|
||||
@reset="reset"
|
||||
@save="save"
|
||||
/>
|
||||
@@ -56,7 +61,10 @@ import { computed, useTemplateRef } from 'vue'
|
||||
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
|
||||
import ValidationMessage from '~/components/ValidationMessage.vue'
|
||||
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'
|
||||
|
||||
const { projectV2: project, currentMember, patchProject } = injectProjectPageContext()
|
||||
@@ -86,8 +94,12 @@ const hasPermission = computed(
|
||||
(currentMember.value.permissions & TeamMemberPermission.EDIT_BODY) ===
|
||||
TeamMemberPermission.EDIT_BODY,
|
||||
)
|
||||
const descriptionValidation = computed(() => validateProjectText(current.value.description))
|
||||
const canSave = computed(() => hasPermission.value && !descriptionValidation.value)
|
||||
const { pending: descriptionLinksPending, validation: descriptionValidation } =
|
||||
useProjectDescriptionValidation(() => current.value.description)
|
||||
const hasValidationIssues = computed(() => descriptionValidation.value?.severity === 'error')
|
||||
const canSave = computed(
|
||||
() => hasPermission.value && !hasValidationIssues.value && !descriptionLinksPending.value,
|
||||
)
|
||||
|
||||
async function save() {
|
||||
if (!canSave.value) return
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
:maxlength="64"
|
||||
placeholder="Enter title..."
|
||||
/>
|
||||
<ValidationMessage :check="galleryTitleValidation" />
|
||||
<label for="gallery-image-desc">
|
||||
<span class="label__title">Description</span>
|
||||
</label>
|
||||
@@ -53,6 +54,7 @@
|
||||
:maxlength="255"
|
||||
placeholder="Enter description..."
|
||||
/>
|
||||
<ValidationMessage :check="galleryDescriptionValidation" />
|
||||
<label for="gallery-image-ordering">
|
||||
<span class="label__title">Order Index</span>
|
||||
</label>
|
||||
@@ -90,7 +92,7 @@
|
||||
v-if="editIndex === -1"
|
||||
type="colored"
|
||||
color="brand"
|
||||
:disabled="shouldPreventActions"
|
||||
:disabled="shouldPreventActions || galleryFieldsInvalid"
|
||||
@click="createGalleryItem"
|
||||
>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
@@ -100,7 +102,7 @@
|
||||
v-else
|
||||
type="colored"
|
||||
color="brand"
|
||||
:disabled="shouldPreventActions"
|
||||
:disabled="shouldPreventActions || galleryFieldsInvalid"
|
||||
@click="editGalleryItem"
|
||||
>
|
||||
<SaveIcon aria-hidden="true" />
|
||||
@@ -286,6 +288,7 @@ import {
|
||||
UploadIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { validateProjectText } from '@modrinth/moderation'
|
||||
import {
|
||||
Button,
|
||||
ButtonLink,
|
||||
@@ -302,6 +305,7 @@ import {
|
||||
} from '@modrinth/ui'
|
||||
|
||||
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
|
||||
import ValidationMessage from '~/components/ValidationMessage.vue'
|
||||
import { fileDeclaresAi } from '~/helpers/c2pa'
|
||||
import { isPermission } from '~/utils/permissions.ts'
|
||||
|
||||
@@ -339,6 +343,11 @@ const editOrder = ref(null)
|
||||
const editFile = ref(null)
|
||||
const previewImage = ref(null)
|
||||
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 acceptFileTypes = 'image/png,image/jpeg,image/gif,image/webp,.png,.jpeg,.gif,.webp'
|
||||
@@ -419,6 +428,7 @@ const showPreviewImage = () => {
|
||||
}
|
||||
|
||||
const createGalleryItem = async () => {
|
||||
if (galleryFieldsInvalid.value) return
|
||||
shouldPreventActions.value = true
|
||||
|
||||
const success = await createGalleryItemMutation(
|
||||
@@ -437,6 +447,7 @@ const createGalleryItem = async () => {
|
||||
}
|
||||
|
||||
const editGalleryItem = async () => {
|
||||
if (galleryFieldsInvalid.value) return
|
||||
shouldPreventActions.value = true
|
||||
|
||||
const success = await editGalleryItemMutation(
|
||||
|
||||
@@ -16,7 +16,10 @@ import {
|
||||
|
||||
import ValidationMessage from '~/components/ValidationMessage.vue'
|
||||
import SlugSuggestions from '~/components/ui/SlugSuggestions.vue'
|
||||
import { validateProjectText } from '~/composables/project-text-validation'
|
||||
import {
|
||||
useProjectSummaryValidation,
|
||||
useProjectTitleValidation,
|
||||
} from '~/composables/project-field-validation'
|
||||
import {
|
||||
useProjectSlugSuggestions,
|
||||
useSlugSuggestionVisibility,
|
||||
@@ -53,8 +56,11 @@ const {
|
||||
|
||||
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
|
||||
|
||||
const titleValidation = computed(() => validateProjectText(current.value.title))
|
||||
const taglineValidation = computed(() => validateProjectText(current.value.tagline))
|
||||
const titleValidation = useProjectTitleValidation(() => current.value.title)
|
||||
const taglineValidation = useProjectSummaryValidation(
|
||||
() => current.value.tagline,
|
||||
() => current.value.title,
|
||||
)
|
||||
const canSave = computed(() => !titleValidation.value && !taglineValidation.value)
|
||||
const {
|
||||
onFocusIn: onSlugSuggestionFocusIn,
|
||||
|
||||
@@ -294,6 +294,11 @@
|
||||
:modified="modified"
|
||||
:saving="saving"
|
||||
:can-save="canSave"
|
||||
:save-disabled-reason="
|
||||
hasPermission && hasValidationIssues
|
||||
? projectTextValidationMessages.resolveIssuesToSave
|
||||
: undefined
|
||||
"
|
||||
@reset="resetChanges"
|
||||
@save="handleSave"
|
||||
/>
|
||||
@@ -334,7 +339,11 @@ import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
|
||||
import SlugSuggestions from '~/components/ui/SlugSuggestions.vue'
|
||||
import ValidationMessage from '~/components/ValidationMessage.vue'
|
||||
import { useAuth } from '~/composables/auth.js'
|
||||
import { validateProjectText } from '~/composables/project-text-validation'
|
||||
import {
|
||||
projectTextValidationMessages,
|
||||
useProjectSummaryValidation,
|
||||
useProjectTitleValidation,
|
||||
} from '~/composables/project-field-validation'
|
||||
import {
|
||||
useProjectSlugSuggestions,
|
||||
useSlugSuggestionVisibility,
|
||||
@@ -424,11 +433,10 @@ const hasPermission = computed(() => {
|
||||
return ((currentMember.value?.permissions ?? 0) & EDIT_DETAILS) === EDIT_DETAILS
|
||||
})
|
||||
|
||||
const nameValidation = computed(() => validateProjectText(name.value))
|
||||
const summaryValidation = computed(() => validateProjectText(summary.value))
|
||||
const canSave = computed(
|
||||
() => hasPermission.value && !nameValidation.value && !summaryValidation.value,
|
||||
)
|
||||
const nameValidation = useProjectTitleValidation(name)
|
||||
const summaryValidation = useProjectSummaryValidation(summary, name)
|
||||
const hasValidationIssues = computed(() => !!nameValidation.value || !!summaryValidation.value)
|
||||
const canSave = computed(() => hasPermission.value && !hasValidationIssues.value)
|
||||
|
||||
const monetizationToggleDisabled = computed(() => !hasPermission.value || isForceDemonetized.value)
|
||||
|
||||
|
||||
@@ -15,12 +15,14 @@
|
||||
"@modrinth/assets": "workspace:*",
|
||||
"@modrinth/utils": "workspace:*",
|
||||
"@modrinth/api-client": "workspace:*",
|
||||
"linkify-it": "^5.0.0",
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formatjs/cli": "^6.2.12",
|
||||
"@modrinth/tooling-config": "workspace:*",
|
||||
"@modrinth/ui": "workspace:*",
|
||||
"@types/linkify-it": "^5.0.0",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,3 +22,4 @@ export * from './utils'
|
||||
export * from './validators/link-checks'
|
||||
export * from './validators/non-standard-text'
|
||||
export * from './validators/profanity'
|
||||
export * from './validators/project-fields'
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
"defaultMessage": "The wiki is disabled on this repository."
|
||||
},
|
||||
"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": {
|
||||
"defaultMessage": "This link points to the {detected} license, but your project is set to {selected}."
|
||||
@@ -343,5 +343,26 @@
|
||||
},
|
||||
"nags.visit-links-settings.title": {
|
||||
"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 {
|
||||
url: string | undefined
|
||||
field: string
|
||||
generalContent?: boolean
|
||||
|
||||
[key: string]: unknown
|
||||
}
|
||||
@@ -226,7 +227,10 @@ async function matchNode(
|
||||
(child) => !isAsyncMatcher(child.when) && !child.isFallback,
|
||||
)
|
||||
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)
|
||||
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
|
||||
const invalidUrlMessage = defineMessage({
|
||||
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 {
|
||||
@@ -314,6 +318,13 @@ function cacheKey(context: LinkCheckContext): string {
|
||||
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) {
|
||||
const url = context.url
|
||||
if (!url) return
|
||||
@@ -327,6 +338,10 @@ async function checkLink(context: LinkCheckContext) {
|
||||
|
||||
const found = await matchNode(rootNode, normalizedUrl, context, true)
|
||||
if (!found) {
|
||||
if (context.generalContent && validUrlPrefix(normalizedUrl) !== null) {
|
||||
cache.set(key, valid)
|
||||
return
|
||||
}
|
||||
cache.delete(key)
|
||||
return
|
||||
}
|
||||
@@ -336,6 +351,11 @@ async function checkLink(context: LinkCheckContext) {
|
||||
const applies = isLeaf && matched.forMatchers?.some((matcher) => matchesField(matcher, context))
|
||||
|
||||
if (!applies) {
|
||||
if (context.generalContent && hasFieldSpecificDescendant(matched)) {
|
||||
cache.set(key, valid)
|
||||
return
|
||||
}
|
||||
|
||||
const build = matched.unrecognizedSeverity === 'warn' ? warn : error
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const matching = {
|
||||
field: 'license',
|
||||
|
||||
@@ -29,6 +29,14 @@ export interface NonStandardTextOptions {
|
||||
|
||||
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]> = [
|
||||
[0x02b0, 0x02ff],
|
||||
[0x1d400, 0x1d7ff],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { validateNonStandardText } from './index.ts'
|
||||
import { getNonStandardTextRatio, validateNonStandardText } from './index.ts'
|
||||
|
||||
test('accepts ordinary multilingual text and punctuation', () => {
|
||||
const result = validateNonStandardText(
|
||||
@@ -119,3 +119,18 @@ test('reports multiple issue categories in source order', () => {
|
||||
['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':
|
||||
specifier: workspace:*
|
||||
version: link:../utils
|
||||
linkify-it:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0
|
||||
vue:
|
||||
specifier: ^3.5.13
|
||||
version: 3.5.27(typescript@5.9.3)
|
||||
@@ -570,6 +573,9 @@ importers:
|
||||
'@modrinth/ui':
|
||||
specifier: workspace:*
|
||||
version: link:../ui
|
||||
'@types/linkify-it':
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0
|
||||
typescript:
|
||||
specifier: ^5.4.5
|
||||
version: 5.9.3
|
||||
|
||||
Reference in New Issue
Block a user