feat: implement more project field validators

This commit is contained in:
tdgao
2026-08-25 23:18:31 -06:00
parent b9a2e95da9
commit 0d61960855
21 changed files with 601 additions and 77 deletions
@@ -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
}
+4 -7
View File
@@ -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)
+2
View File
@@ -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"
} }
} }
+1
View File
@@ -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',
)
})
+4 -1
View File
@@ -1,3 +1,6 @@
{ {
"extends": "@modrinth/tooling-config/typescript/vue.json" "extends": "@modrinth/tooling-config/typescript/vue.json",
"compilerOptions": {
"allowImportingTsExtensions": true
}
} }
+6
View File
@@ -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