diff --git a/apps/frontend/src/components/ui/create/ProjectCreateModal.vue b/apps/frontend/src/components/ui/create/ProjectCreateModal.vue
index 99b1dc9975..ed1c99ea71 100644
--- a/apps/frontend/src/components/ui/create/ProjectCreateModal.vue
+++ b/apps/frontend/src/components/ui/create/ProjectCreateModal.vue
@@ -40,6 +40,7 @@
autocomplete="off"
:disabled="hasHitLimit"
@update:model-value="updatedName()"
+ @blur="nameForValidation = name"
/>
@@ -334,7 +335,8 @@ const visibilities = ref([
])
const visibility = ref(visibilities.value[0])
-const nameValidation = useProjectTitleValidation(name)
+const nameForValidation = ref(name.value)
+const nameValidation = useProjectTitleValidation(nameForValidation)
const summaryValidation = useProjectSummaryValidation(description, name)
const disableCreate = computed(() => {
@@ -514,6 +516,7 @@ async function createProject() {
async function show(event?: MouseEvent, options?: ShowOptions) {
name.value = ''
+ nameForValidation.value = name.value
slug.value = ''
description.value = ''
manualSlug.value = false
diff --git a/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue b/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue
index cb5a0452dd..9fdffb53fc 100644
--- a/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue
+++ b/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue
@@ -59,14 +59,14 @@
-
+
diff --git a/apps/frontend/src/pages/[type]/[project]/settings/index.vue b/apps/frontend/src/pages/[type]/[project]/settings/index.vue
index 8c7b7b3b61..e7734ad7f9 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings/index.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings/index.vue
@@ -22,7 +22,14 @@
-
+
@@ -430,7 +437,8 @@ const hasPermission = computed(() => {
)
})
-const nameValidation = useProjectTitleValidation(name)
+const nameForValidation = ref(name.value)
+const nameValidation = useProjectTitleValidation(nameForValidation)
const summaryValidation = useProjectSummaryValidation(summary, name)
const hasValidationIssues = computed(
() =>
@@ -537,6 +545,7 @@ const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
function resetChanges() {
name.value = project.value.name
+ nameForValidation.value = name.value
slug.value = project.value.slug ?? ''
summary.value = project.value.summary
visibility.value = tags.value.approvedStatuses.includes(project.value.status)
diff --git a/apps/frontend/src/pages/moderation/index.vue b/apps/frontend/src/pages/moderation/index.vue
index 0fe88fc452..77cfe3276f 100644
--- a/apps/frontend/src/pages/moderation/index.vue
+++ b/apps/frontend/src/pages/moderation/index.vue
@@ -222,7 +222,6 @@ const route = useRoute()
const router = useRouter()
const client = injectModrinthClient()
const queryClient = useQueryClient()
-const generatedState = useGeneratedState()
const debugValidationFilter = useDebugLogger('moderation-validation-filter')
const debugProjectIdsFilter = useDebugLogger('moderation-project-ids-filter')
@@ -598,10 +597,6 @@ const {
const response = await scanProjectsWithValidationIssues({
client,
request: queryKey[3],
- titleMetadata: {
- gameVersions: generatedState.value.gameVersions.map(({ version }) => version),
- loaders: generatedState.value.loaders.map(({ name }) => name),
- },
includeWarnings: queryKey[2],
signal,
log: debugValidationFilter,
diff --git a/apps/frontend/src/services/moderation/validation-filter.ts b/apps/frontend/src/services/moderation/validation-filter.ts
index 93bfca9f22..cacc8cec4e 100644
--- a/apps/frontend/src/services/moderation/validation-filter.ts
+++ b/apps/frontend/src/services/moderation/validation-filter.ts
@@ -1,5 +1,5 @@
import type { AbstractModrinthClient, Labrinth } from '@modrinth/api-client'
-import { type ProjectTitleMetadata, validateProjectFields } from '@modrinth/moderation'
+import { validateProjectFields } from '@modrinth/moderation'
export type ValidationFilterRequest = Omit<
Labrinth.Moderation.Internal.ProjectsRequest,
@@ -16,7 +16,6 @@ export interface ModerationQueueFetchOptions {
interface ValidationFilterScanOptions {
client: AbstractModrinthClient
request: ValidationFilterRequest
- titleMetadata: ProjectTitleMetadata
includeWarnings: boolean
signal: AbortSignal
log: (message: string) => void
@@ -130,7 +129,6 @@ export async function fetchAllModerationQueueProjects(
export async function scanProjectsWithValidationIssues({
client,
request,
- titleMetadata,
includeWarnings,
signal,
log,
@@ -170,7 +168,7 @@ export async function scanProjectsWithValidationIssues({
if (!project) {
throw new Error(`V3 projects response omitted queued project ${projectId}`)
}
- const validation = validateProjectFields(project, titleMetadata)
+ const validation = validateProjectFields(project)
if (includeWarnings ? validation.failures.length > 0 : !validation.valid) {
matchingProjectIds.add(projectId)
}
diff --git a/packages/moderation/src/data/nags/project-validation.ts b/packages/moderation/src/data/nags/project-validation.ts
index 573c65165d..963094cecf 100644
--- a/packages/moderation/src/data/nags/project-validation.ts
+++ b/packages/moderation/src/data/nags/project-validation.ts
@@ -14,10 +14,6 @@ const nameErrorCodes: readonly ProjectTextValidationCode[] = [
'text-profanity',
'text-non-standard',
]
-const nameWarningCodes: readonly ProjectTextValidationCode[] = [
- 'title-game-version',
- 'title-loader',
-]
const summaryErrorCodes: readonly ProjectTextValidationCode[] = [
'text-slur',
'text-profanity',
@@ -93,15 +89,14 @@ export const projectValidationNags: Nag[] = [
},
},
{
- id: 'project-name-metadata',
+ id: 'project-name-version',
title: defineMessage({
- id: 'nags.project-name-metadata.title',
- defaultMessage: 'Remove technical details from the name',
+ id: 'nags.project-name-version.title',
+ defaultMessage: 'Fix project name',
}),
- description: (context) => getFailureDescription(context, ['name'], 'warn', nameWarningCodes),
- status: 'warning',
- shouldShow: (context) =>
- getFirstFailure(context, ['name'], 'warn', nameWarningCodes) !== undefined,
+ description: (context) => getCodedFailureDescription(context, 'title-version-number'),
+ status: 'required',
+ shouldShow: (context) => getCodedFailure(context, 'title-version-number') !== undefined,
link: {
path: 'settings',
title: defineMessage({
diff --git a/packages/moderation/src/locales/en-US/index.json b/packages/moderation/src/locales/en-US/index.json
index 9cfb1350c1..924224cdab 100644
--- a/packages/moderation/src/locales/en-US/index.json
+++ b/packages/moderation/src/locales/en-US/index.json
@@ -227,8 +227,8 @@
"nags.multiple-resolution-tags.title": {
"defaultMessage": "Select correct resolution"
},
- "nags.project-name-metadata.title": {
- "defaultMessage": "Remove technical details from the name"
+ "nags.project-name-version.title": {
+ "defaultMessage": "Fix project name"
},
"nags.project-summary-content.title": {
"defaultMessage": "Review the project summary"
@@ -351,7 +351,7 @@
"defaultMessage": "Visit links settings"
},
"project.text-validation.description-profanity": {
- "defaultMessage": "Excessive profanity is not allowed. Detected: {values}."
+ "defaultMessage": "Excessive profanity is not allowed. Detected: {values}"
},
"project.text-validation.non-standard-text": {
"defaultMessage": "Non-standard text characters are not allowed."
@@ -371,10 +371,7 @@
"project.text-validation.summary-too-short": {
"defaultMessage": "Your summary is {length, plural, one {# character} other {# characters}}. At least {minChars, plural, one {# character} other {# characters}} is recommended to create an informative and enticing summary."
},
- "project.text-validation.title-game-version": {
- "defaultMessage": "Project titles should not include the Minecraft version “{value}”."
- },
- "project.text-validation.title-loader": {
- "defaultMessage": "Project titles should not include the loader “{value}”."
+ "project.text-validation.title-version-number": {
+ "defaultMessage": "Names are not allowed to include version numbers."
}
}
diff --git a/packages/moderation/src/validators/project-fields/index.ts b/packages/moderation/src/validators/project-fields/index.ts
index 798ec96e94..7cb4e52256 100644
--- a/packages/moderation/src/validators/project-fields/index.ts
+++ b/packages/moderation/src/validators/project-fields/index.ts
@@ -26,8 +26,7 @@ export type ProjectTextValidationCode =
| 'text-slur'
| 'text-profanity'
| 'text-non-standard'
- | 'title-game-version'
- | 'title-loader'
+ | 'title-version-number'
| 'title-minecraft-branding'
| 'summary-link'
| 'summary-matches-title'
@@ -68,13 +67,9 @@ const messages = defineMessages({
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 should not include the Minecraft version “{value}”.',
- },
- titleLoader: {
- id: 'project.text-validation.title-loader',
- defaultMessage: 'Project titles should not include the loader “{value}”.',
+ titleVersionNumber: {
+ id: 'project.text-validation.title-version-number',
+ defaultMessage: 'Names are not allowed to include version numbers.',
},
titleMinecraftBranding: {
id: 'nags.minecraft-title-clause.description',
@@ -126,23 +121,6 @@ const messages = defineMessages({
},
})
-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,
@@ -153,28 +131,6 @@ 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 = [
- ['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')
}
@@ -325,22 +281,23 @@ export function validateProjectText(
export function validateProjectTitle(
text: string | null | undefined,
- metadata: ProjectTitleMetadata,
): ProjectTextValidationResult[] {
const results = validateProjectText(text)
if (results.length > 0 || !text) return results
- const match = findProjectTitleMetadata(text, metadata)
- if (match) {
+ const normalizedTitle = normalizeForSearch(text)
+ const disallowedVersion = [...normalizedTitle.matchAll(/\d+(?:\.\d+)+/g)].find((match) => {
+ const textAfterVersion = normalizedTitle.slice((match.index ?? 0) + match[0].length)
+ return !/\b(?:port|fork)\b/.test(textAfterVersion)
+ })
+ if (disallowedVersion) {
results.push({
- code: match.kind === 'game-version' ? 'title-game-version' : 'title-loader',
- severity: 'warn',
- message: titleMetadataMessages[match.kind],
- values: { value: match.value },
+ code: 'title-version-number',
+ severity: 'error',
+ message: messages.titleVersionNumber,
})
}
- const normalizedTitle = normalizeProjectFieldText(text).toLowerCase()
const wordsInTitle = normalizedTitle.split(/\s+/).filter(Boolean)
if (normalizedTitle.includes('minecraft') && wordsInTitle.length <= 3) {
results.push({
diff --git a/packages/moderation/src/validators/project-fields/tests.ts b/packages/moderation/src/validators/project-fields/tests.ts
index 05f169e9dd..6437ba9fbb 100644
--- a/packages/moderation/src/validators/project-fields/tests.ts
+++ b/packages/moderation/src/validators/project-fields/tests.ts
@@ -4,7 +4,6 @@ import test from 'node:test'
import {
containsProjectLinkOrIp,
extractProjectLinks,
- findProjectTitleMetadata,
projectSummaryMatchesTitle,
validateProjectDescription,
validateProjectSummary,
@@ -12,24 +11,6 @@ import {
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)
@@ -72,26 +53,37 @@ test('validates shared project text', () => {
})
test('validates project titles', () => {
- assert.deepEqual(validateProjectTitle('Fabric Tools', metadata), [
+ assert.deepEqual(validateProjectTitle('Tools 1.2.3'), [
{
- code: 'title-loader',
- severity: 'warn',
+ code: 'title-version-number',
+ severity: 'error',
message: {
- id: 'project.text-validation.title-loader',
- defaultMessage: 'Project titles should not include the loader “{value}”.',
+ id: 'project.text-validation.title-version-number',
+ defaultMessage: 'Names are not allowed to include version numbers.',
},
- values: { value: 'fabric' },
},
])
- assert.equal(
- validateProjectTitle('Minecraft Tools', metadata)[0]?.code,
- 'title-minecraft-branding',
- )
+ assert.deepEqual(validateProjectTitle('Tools 1.2'), [
+ {
+ code: 'title-version-number',
+ severity: 'error',
+ message: {
+ id: 'project.text-validation.title-version-number',
+ defaultMessage: 'Names are not allowed to include version numbers.',
+ },
+ },
+ ])
+ assert.deepEqual(validateProjectTitle('My Mod 1.2 Fabric Port'), [])
+ assert.deepEqual(validateProjectTitle('My Mod 1.2 FORK'), [])
+ assert.equal(validateProjectTitle('My Port of Mod 1.2')[0]?.code, 'title-version-number')
+ assert.equal(validateProjectTitle('My Mod 1.2 Supported')[0]?.code, 'title-version-number')
+ assert.deepEqual(validateProjectTitle('Fabric Tools'), [])
+ assert.equal(validateProjectTitle('Minecraft Tools')[0]?.code, 'title-minecraft-branding')
assert.deepEqual(
- validateProjectTitle('Minecraft Fabric Tools', metadata).map(({ code }) => code),
- ['title-loader', 'title-minecraft-branding'],
+ validateProjectTitle('Minecraft Tools 1.2').map(({ code }) => code),
+ ['title-version-number', 'title-minecraft-branding'],
)
- assert.deepEqual(validateProjectTitle('Ordinary Tools', metadata), [])
+ assert.deepEqual(validateProjectTitle('Ordinary Tools'), [])
})
test('validates project summaries', () => {
diff --git a/packages/moderation/src/validators/project-validation/index.ts b/packages/moderation/src/validators/project-validation/index.ts
index 54f655ce7d..bf6c4e57fa 100644
--- a/packages/moderation/src/validators/project-validation/index.ts
+++ b/packages/moderation/src/validators/project-validation/index.ts
@@ -2,7 +2,6 @@ import type { Labrinth } from '@modrinth/api-client'
import {
type ProjectTextValidationResult,
- type ProjectTitleMetadata,
validateProjectDescription,
validateProjectSummary,
validateProjectText,
@@ -29,7 +28,6 @@ export interface ProjectValidationResult {
export function validateProjectFields(
project: Labrinth.Projects.v3.Project,
- titleMetadata: ProjectTitleMetadata,
): ProjectValidationResult {
const failures: ProjectValidationFailure[] = []
@@ -47,7 +45,7 @@ export function validateProjectFields(
)
}
- addFailures('name', validateProjectTitle(project.name, titleMetadata))
+ addFailures('name', validateProjectTitle(project.name))
addFailures('summary', validateProjectSummary(project.summary, project.name))
addFailures('description', validateProjectDescription(project.description))
@@ -67,9 +65,6 @@ export function validateProjectFields(
}
}
-export function hasProjectFieldValidationFailures(
- project: Labrinth.Projects.v3.Project,
- titleMetadata: ProjectTitleMetadata,
-): boolean {
- return !validateProjectFields(project, titleMetadata).valid
+export function hasProjectFieldValidationFailures(project: Labrinth.Projects.v3.Project): boolean {
+ return !validateProjectFields(project).valid
}
diff --git a/packages/moderation/src/validators/project-validation/tests.ts b/packages/moderation/src/validators/project-validation/tests.ts
index af165e51ef..1a603bc18b 100644
--- a/packages/moderation/src/validators/project-validation/tests.ts
+++ b/packages/moderation/src/validators/project-validation/tests.ts
@@ -3,14 +3,8 @@ import test from 'node:test'
import type { Labrinth } from '@modrinth/api-client'
-import type { ProjectTitleMetadata } from '../project-fields/index.ts'
import { hasProjectFieldValidationFailures, validateProjectFields } from './index.ts'
-const metadata: ProjectTitleMetadata = {
- gameVersions: ['1.21.1'],
- loaders: ['fabric'],
-}
-
function createProject(
overrides: Partial = {},
): Labrinth.Projects.v3.Project {
@@ -25,8 +19,8 @@ function createProject(
test('validates project fields and gallery text', () => {
const project = createProject({
- name: 'Fabric Tools',
- summary: 'Fabric Tools',
+ name: 'Ordinary Tools',
+ summary: 'Ordinary Tools',
description: '𝐀',
gallery: [
{
@@ -41,7 +35,7 @@ test('validates project fields and gallery text', () => {
],
})
- const result = validateProjectFields(project, metadata)
+ const result = validateProjectFields(project)
assert.equal(result.valid, false)
assert.deepEqual(
@@ -52,12 +46,6 @@ test('validates project fields and gallery text', () => {
message: message.id,
})),
[
- {
- field: 'name',
- galleryIndex: undefined,
- galleryUrl: undefined,
- message: 'project.text-validation.title-loader',
- },
{
field: 'summary',
galleryIndex: undefined,
@@ -93,36 +81,40 @@ test('reports whether a project has field validation failures', () => {
const validProject = createProject()
const invalidProject = createProject({ summary: 'This project is shit' })
- assert.deepEqual(validateProjectFields(validProject, metadata), {
+ assert.deepEqual(validateProjectFields(validProject), {
valid: true,
failures: [],
})
- assert.equal(hasProjectFieldValidationFailures(validProject, metadata), false)
- assert.equal(hasProjectFieldValidationFailures(invalidProject, metadata), true)
+ assert.equal(hasProjectFieldValidationFailures(validProject), false)
+ assert.equal(hasProjectFieldValidationFailures(invalidProject), true)
})
-test('treats title metadata and summary content recommendations as warnings', () => {
+test('treats version numbers as errors and summary content recommendations as warnings', () => {
const project = createProject({
- name: 'Fabric Tools',
+ name: 'Tools 1.2.3',
summary: 'Visit modrinth.com for more information',
})
- const result = validateProjectFields(project, metadata)
+ const result = validateProjectFields(project)
- assert.equal(result.valid, true)
+ assert.equal(result.valid, false)
assert.deepEqual(
result.failures.map(({ code, severity }) => ({ code, severity })),
[
- { code: 'title-loader', severity: 'warn' },
+ { code: 'title-version-number', severity: 'error' },
{ code: 'summary-link', severity: 'warn' },
],
)
- assert.equal(hasProjectFieldValidationFailures(project, metadata), false)
+ assert.equal(hasProjectFieldValidationFailures(project), true)
+ assert.equal(
+ hasProjectFieldValidationFailures(createProject({ name: 'Tools 1.2 Fabric Port' })),
+ false,
+ )
})
test('reports summary recommendations without invalidating the project', () => {
const project = createProject({ summary: 'Short summary' })
- assert.deepEqual(validateProjectFields(project, metadata), {
+ assert.deepEqual(validateProjectFields(project), {
valid: true,
failures: [
{
@@ -138,5 +130,5 @@ test('reports summary recommendations without invalidating the project', () => {
},
],
})
- assert.equal(hasProjectFieldValidationFailures(project, metadata), false)
+ assert.equal(hasProjectFieldValidationFailures(project), false)
})