refactor: centralize project validation and nags into validation rules

This commit is contained in:
tdgao
2026-08-27 22:03:04 -06:00
parent 1f3f37c86c
commit 18c1f31514
83 changed files with 2752 additions and 3428 deletions
+31 -13
View File
@@ -1,14 +1,32 @@
import type { Nag } from '../types/nags'
import { coreNags } from './nags/core'
import { linksNags } from './nags/links'
import { projectValidationNags } from './nags/project-validation'
import { serverProjectsNags } from './nags/server-projects'
import { tagsNags } from './nags/tags'
import type { Nag, ProjectValidationContext } from '../types/nags.ts'
import { getDescriptionNags } from '../validation-rules/rules/description.ts'
import { getDisclosureNags } from '../validation-rules/rules/disclosures.ts'
import { getGalleryNags } from '../validation-rules/rules/gallery.ts'
import { getIconNags } from '../validation-rules/rules/icon.ts'
import { getLicenseNags } from '../validation-rules/rules/license.ts'
import { getLinksNags } from '../validation-rules/rules/links.ts'
import { getModerationNags } from '../validation-rules/rules/moderation.ts'
import { getNameNags } from '../validation-rules/rules/name.ts'
import { getPermissionsNags } from '../validation-rules/rules/permissions.ts'
import { getServerSettingsNags } from '../validation-rules/rules/server-settings.ts'
import { getSummaryNags } from '../validation-rules/rules/summary.ts'
import { getTagsNags } from '../validation-rules/rules/tags.ts'
import { getVersionNags } from '../validation-rules/rules/versions.ts'
export default [
...coreNags,
...linksNags,
...projectValidationNags,
...tagsNags,
...serverProjectsNags,
] as Nag[]
export function getNags(context: ProjectValidationContext): Nag[] {
return [
...getNameNags(context),
...getSummaryNags(context),
...getIconNags(context),
...getGalleryNags(context),
...getDescriptionNags(context),
...getLicenseNags(context),
...getLinksNags(context),
...getPermissionsNags(context),
...getServerSettingsNags(context),
...getTagsNags(context),
...getVersionNags(context),
...getDisclosureNags(context),
...getModerationNags(context),
]
}
-291
View File
@@ -1,291 +0,0 @@
import { defineMessage, formatProjectTypeSentence, useVIntl } from '@modrinth/ui'
import type { Nag, NagContext } from '../../types/nags'
export const coreNags: Nag[] = [
{
id: 'moderator-feedback',
title: defineMessage({
id: 'nags.moderator-feedback.title',
defaultMessage: 'Review feedback',
}),
description: defineMessage({
id: 'nags.moderator-feedback.description',
defaultMessage:
'Review and address all concerns from the moderation team before resubmitting.',
}),
status: 'warning',
shouldShow: (context: NagContext) =>
context.tags.rejectedStatuses.includes(context.project.status),
link: {
path: 'moderation',
title: defineMessage({
id: 'nags.moderation.title',
defaultMessage: 'Visit moderation thread',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-moderation',
},
},
{
id: 'upload-version',
title: defineMessage({
id: 'nags.upload-version.title',
defaultMessage: 'Upload a version',
}),
description: defineMessage({
id: 'nags.upload-version.description',
defaultMessage: 'At least one version is required for a project to be submitted for review.',
}),
status: 'required',
shouldShow: (context: NagContext) =>
context.projectV3?.versions?.length < 1 && !context.projectV3?.minecraft_server,
link: {
path: 'settings/versions',
title: defineMessage({
id: 'nags.settings.versions.title',
defaultMessage: 'Visit versions settings',
}),
shouldShow: (context: NagContext) =>
context.currentRoute !== 'type-project-settings-versions',
},
},
{
id: 'add-icon',
title: defineMessage({
id: 'nags.add-icon.title',
defaultMessage: 'Add an icon',
}),
description: defineMessage({
id: 'nags.add-icon.description',
defaultMessage:
'Adding a unique, relevant, and engaging icon makes your project identifiable and helps it stand out.',
}),
status: 'suggestion',
shouldShow: (context: NagContext) => !context.project.icon_url,
link: {
path: 'settings',
title: defineMessage({
id: 'nags.settings.title',
defaultMessage: 'Visit general settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings',
},
},
{
id: 'upload-gallery-image',
title: defineMessage({
id: 'nags.upload-gallery-image.title',
defaultMessage: 'Upload a gallery image',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
if (context.projectV3?.project_types.includes('resourcepack')) {
return formatMessage(
defineMessage({
id: 'nags.upload-gallery-image.description-resourcepack',
defaultMessage:
'At least one gallery image is required to showcase the content of your resource pack, except for audio or localization packs. If this describes your pack, please select the appropriate tag.',
}),
)
} else if (context.projectV3?.project_types.includes('shader')) {
return formatMessage(
defineMessage({
id: 'nags.upload-gallery-image.description-shader',
defaultMessage:
'At least three gallery images are required to showcase the content of your shader in a variety of situations and conditions.',
}),
)
}
return formatMessage(
defineMessage({
id: 'nags.upload-gallery-image.description',
defaultMessage:
'At least one gallery image is required to showcase the content of your {type}.',
}),
{
type: formatProjectTypeSentence(formatMessage, context.project.project_type),
},
)
},
status: 'required',
shouldShow: (context: NagContext) => {
return (
(context.projectV3?.project_types.includes('shader') &&
context.project.gallery &&
context.project.gallery?.length < 3) ||
(context.projectV3?.project_types.includes('resourcepack') &&
context.project.gallery &&
context.project.gallery?.length === 0 &&
!(
context.project.categories.includes('audio') ||
context.project.additional_categories.includes('audio') ||
context.project.categories.includes('locale') ||
context.project.additional_categories.includes('locale')
)) ||
false
)
},
link: {
path: 'settings/gallery',
title: defineMessage({
id: 'nags.gallery.title',
defaultMessage: 'Visit gallery page',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-gallery',
},
},
{
id: 'feature-gallery-image',
title: defineMessage({
id: 'nags.feature-gallery-image.title',
defaultMessage: 'Feature a gallery image',
}),
description: defineMessage({
id: 'nags.feature-gallery-image.description',
defaultMessage:
'The featured gallery image is often how your project makes its first impression.',
}),
status: 'suggestion',
shouldShow: (context: NagContext) => {
if (context.projectV3?.minecraft_server) return false
const featuredGalleryImage = context.project.gallery?.find((img) => img.featured)
return context.project?.gallery?.length === 0 || !featuredGalleryImage
},
link: {
path: 'settings/gallery',
title: defineMessage({
id: 'nags.gallery.title',
defaultMessage: 'Visit gallery page',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-gallery',
},
},
{
id: 'select-license',
title: defineMessage({
id: 'nags.select-license.title',
defaultMessage: 'Select a license',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
return formatMessage(
defineMessage({
id: 'nags.select-license.description',
defaultMessage: 'Select the license your {type} is distributed under.',
}),
{
type: formatProjectTypeSentence(formatMessage, context.project.project_type),
},
)
},
status: 'required',
shouldShow: (context: NagContext) =>
(context.project.license.id === 'LicenseRef-Unknown' ||
context.project.license.id === 'NOASSERTION' ||
context.project.license.id === 'LicenseRef-NOASSERTION') &&
!context.projectV3?.minecraft_server,
link: {
path: 'settings/license',
title: defineMessage({
id: 'nags.settings.license.title',
defaultMessage: 'Visit license settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings-license',
},
},
{
id: 'add-custom-license-details',
title: defineMessage({
id: 'nags.add-license-details.title',
defaultMessage: 'Add license details',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
return formatMessage(
defineMessage({
id: 'nags.add-license-details.description',
defaultMessage: 'Add a valid URL and name or SPDX identifier for your custom license.',
}),
{
type: formatProjectTypeSentence(formatMessage, context.project.project_type),
},
)
},
status: 'required',
shouldShow: (context: NagContext) =>
!context.projectV3?.minecraft_server &&
(context.project.license.id === 'LicenseRef-' ||
(context.project.license.id.search('LicenseRef-') === 0 &&
(!context.project.license.url || context.project.license.url === '') &&
context.project.license.id !== 'LicenseRef-Unknown' &&
context.project.license.id !== 'LicenseRef-All-Rights-Reserved')),
link: {
path: 'settings/license',
title: defineMessage({
id: 'nags.settings.license.title',
defaultMessage: 'Visit license settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings-license',
},
},
{
id: 'review-permissions',
title: defineMessage({
id: 'nags.review-permissions.title',
defaultMessage: 'Review external permissions',
}),
description: (_context: NagContext) => {
const { formatMessage } = useVIntl()
return formatMessage(
defineMessage({
id: 'nags.review-permissions.description',
defaultMessage:
'Make sure you have provided proof of your permission to distribute any external content in your Modpack.',
}),
)
},
status: 'required',
shouldShow: (context: NagContext) =>
context.versions.some((version) => (version.files_missing_attribution?.length ?? 0) >= 1),
link: {
path: 'settings/permissions',
title: defineMessage({
id: 'nags.settings.permissions.title',
defaultMessage: 'Visit permissions dashboard',
}),
shouldShow: (context: NagContext) =>
context.currentRoute !== 'type-project-settings-permissions',
},
},
{
id: 'check-disclosures',
title: defineMessage({
id: 'nags.check-disclosures.title',
defaultMessage: 'Check content disclosures',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
return formatMessage(
defineMessage({
id: 'nags.check-disclosures.description',
defaultMessage:
'Make sure users are aware of any important details by filling in content disclosures that apply to your {type}.',
}),
{ type: formatProjectTypeSentence(formatMessage, context.project.project_type) },
)
},
status: 'suggestion',
shouldShow: () => true,
link: {
path: 'settings/disclosures',
title: defineMessage({
id: 'nags.settings.disclosures.title',
defaultMessage: 'Visit disclosure settings',
}),
shouldShow: (context: NagContext) =>
context.currentRoute !== 'type-project-settings-disclosures',
},
},
]
@@ -1,5 +0,0 @@
export * from './core'
export * from './links'
export * from './project-validation'
export * from './server-projects'
export * from './tags'
-295
View File
@@ -1,295 +0,0 @@
import { defineMessage, formatProjectTypeSentence, useVIntl } from '@modrinth/ui'
import type { Nag, NagContext } from '../../types/nags'
import { licenseRequiresSource, notSourceAsDistributed } from '../../utils'
import {
getBlockedProjectExternalLink,
getLinkHostname,
isCommonProjectLink,
isDiscordLink,
isInappropriateLicenseLink,
} from '../../validators/links'
export function findBlockedProjectExternalLink(context: Pick<NagContext, 'project' | 'projectV3'>) {
const urls = [
context.project.source_url,
context.project.issues_url,
context.project.wiki_url,
context.project.discord_url,
context.project.license.url,
...(context.project.donation_urls ?? []).map(({ url }) => url),
...Object.values(context.projectV3?.link_urls ?? {}).map(({ url }) => url),
]
for (const url of urls) {
if (!url) continue
const blockedLink = getBlockedProjectExternalLink(url)
if (blockedLink) return blockedLink
}
return null
}
export const linksNags: Nag[] = [
{
id: 'add-links',
title: defineMessage({
id: 'nags.add-links.title',
defaultMessage: 'Add external links',
}),
description: defineMessage({
id: 'nags.add-links.description',
defaultMessage:
'Add any relevant links targeted outside of Modrinth, such as source code, an issue tracker, or a Discord invite.',
}),
status: 'suggestion',
shouldShow: (context: NagContext) => {
return (
!context.projectV3?.minecraft_server &&
Object.keys(context.projectV3?.link_urls ?? {}).length === 0
)
},
link: {
path: 'settings/links',
title: defineMessage({
id: 'nags.settings.links.title',
defaultMessage: 'Visit links settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings-links',
},
},
{
id: 'add-links-server',
title: defineMessage({
id: 'nags.add-links-server.title',
defaultMessage: 'Add external links',
}),
description: defineMessage({
id: 'nags.add-links-server.description',
defaultMessage:
'Add any relevant links targeted outside of Modrinth, such as a website, store, or a Discord invite.',
}),
status: 'suggestion',
shouldShow: (context: NagContext) => {
return (
!!context.projectV3?.minecraft_server &&
Object.keys(context.projectV3?.link_urls ?? {}).length === 0
)
},
link: {
path: 'settings/links',
title: defineMessage({
id: 'nags.settings.links.title',
defaultMessage: 'Visit links settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings-links',
},
},
{
id: 'identical-links',
title: defineMessage({
id: 'nags.identical-links.title',
defaultMessage: 'Clean up identical links',
}),
description: defineMessage({
id: 'nags.identical-links.description',
defaultMessage:
'Some of your external links appear to be identical. Each link should be entered only once and with the appropriate link type.',
}),
status: 'required',
shouldShow: (context: NagContext) =>
new Set(Object.values(context.projectV3?.link_urls ?? {}).map((link) => link.url)).size !==
Object.values(context.projectV3?.link_urls ?? {}).map((link) => link.url).length,
link: {
path: 'settings/links',
title: defineMessage({
id: 'nags.settings.links.title',
defaultMessage: 'Visit links settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings-links',
},
},
{
id: 'verify-external-links',
title: defineMessage({
id: 'nags.verify-external-links.title',
defaultMessage: 'Verify external links',
}),
description: defineMessage({
id: 'nags.verify-external-links.description',
defaultMessage:
'Some of your external links may be using domains that are inappropriate for that type of link.',
}),
status: 'warning',
shouldShow: (context: NagContext) => {
const sourceUrl = context.project.source_url
const issuesUrl = context.project.issues_url
const discordUrl = context.project.discord_url
return (
(!!sourceUrl && !isCommonProjectLink(sourceUrl, 'source')) ||
(!!issuesUrl && !isCommonProjectLink(issuesUrl, 'issues')) ||
(!!discordUrl && !isCommonProjectLink(discordUrl, 'discord'))
)
},
link: {
path: 'settings/links',
title: defineMessage({
id: 'nags.visit-links-settings.title',
defaultMessage: 'Visit links settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings-links',
},
},
{
id: 'misused-discord-link',
title: defineMessage({
id: 'nags.misused-discord-link.title',
defaultMessage: 'Move Discord invite',
}),
description: defineMessage({
id: 'nags.misused-discord-link-description',
defaultMessage:
'Discord invites can not be used for other link types. Please put your Discord link in the Discord Invite link field only.',
}),
status: 'required',
shouldShow: (context: NagContext) =>
isDiscordLink(context.project.source_url) ||
isDiscordLink(context.project.issues_url) ||
isDiscordLink(context.project.wiki_url) ||
isDiscordLink(context.projectV3?.link_urls?.site?.url) ||
isDiscordLink(context.projectV3?.link_urls?.store?.url),
link: {
path: 'settings/links',
title: defineMessage({
id: 'nags.visit-links-settings.title',
defaultMessage: 'Visit links settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings-links',
},
},
{
id: 'banned-link-usage',
title: defineMessage({
id: 'nags.banned-link-usage.title',
defaultMessage: 'Remove prohibited links',
}),
description: (context: NagContext) => {
const blockedLink = findBlockedProjectExternalLink(context)
if (!blockedLink) return ''
const { formatMessage } = useVIntl()
return formatMessage(
defineMessage({
id: 'nags.banned-link-usage.description',
defaultMessage: '“{url}” is not allowed in project links.',
}),
blockedLink,
)
},
status: 'required',
shouldShow: (context: NagContext) => findBlockedProjectExternalLink(context) !== null,
},
{
id: 'invalid-license-url',
title: defineMessage({
id: 'nags.invalid-license-url.title',
defaultMessage: 'Add a valid license link',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const licenseUrl = context.project.license.url
if (!licenseUrl) {
return formatMessage(
defineMessage({
id: 'nags.invalid-license-url.description.default',
defaultMessage: 'License URL is invalid.',
}),
)
}
const domain = getLinkHostname(licenseUrl)
if (domain) {
return formatMessage(
defineMessage({
id: 'nags.invalid-license-url.description.domain',
defaultMessage:
'Your license URL points to {domain}, which is not appropriate for license information. License URLs should link directly to your license file, not social media, gaming platforms, etc.',
}),
{ domain },
)
}
return formatMessage(
defineMessage({
id: 'nags.invalid-license-url.description.malformed',
defaultMessage:
'Your license URL appears to be malformed. Please provide a valid URL to your license text.',
}),
)
},
status: 'required',
shouldShow: (context: NagContext) => {
const licenseUrl = context.project.license.url
if (!licenseUrl) return false
return getLinkHostname(licenseUrl) === null || isInappropriateLicenseLink(licenseUrl)
},
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-license.title',
defaultMessage: 'Edit license',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings',
},
},
{
id: 'gpl-license-source-required',
title: defineMessage({
id: 'nags.gpl-license-source-required.title',
defaultMessage: 'Provide source code',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
return formatMessage(
defineMessage({
id: 'nags.gpl-license-source-required.description',
defaultMessage:
'Your {type} uses a license which requires source code to be available. Please provide a source code link or sources file for each additional version, or consider using a different license.',
}),
{
type: formatProjectTypeSentence(formatMessage, context.project.project_type),
},
)
},
status: 'required',
shouldShow: (context: NagContext) => {
if (context.projectV3.project_types.includes('datapack')) return false
const hasSourceUrl = !!context.project.source_url
const hasAdditionalFiles = (context: NagContext) => {
let hasAdditional = true
context.versions.forEach((version) => {
if (version.files.length < 2) hasAdditional = false
})
return hasAdditional
}
return (
licenseRequiresSource(context.projectV3.license.id) &&
notSourceAsDistributed(context.projectV3.project_types) &&
!hasSourceUrl &&
!hasAdditionalFiles(context)
)
},
link: {
path: 'settings/links',
title: defineMessage({
id: 'nags.visit-links-settings.title',
defaultMessage: 'Visit links settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings-links',
},
},
]
@@ -1,333 +0,0 @@
import { defineMessage, useVIntl } from '@modrinth/ui'
import type { Nag, NagContext } from '../../types/nags'
import type { ProjectTextValidationCode } from '../../validators/project-fields/index.ts'
import type {
ProjectValidationFailure,
ProjectValidationField,
} from '../../validators/project-validation/index.ts'
const generalSettingsRoutes = new Set(['type-project-settings', 'type-project-settings-general'])
const nameErrorCodes: readonly ProjectTextValidationCode[] = [
'text-slur',
'text-profanity',
'text-non-standard',
]
const summaryErrorCodes: readonly ProjectTextValidationCode[] = [
'text-slur',
'text-profanity',
'text-non-standard',
'text-banned-link',
'summary-link',
]
const summaryRequiredCodes: readonly ProjectTextValidationCode[] = ['summary-matches-title']
const descriptionErrorCodes: readonly ProjectTextValidationCode[] = [
'text-slur',
'text-profanity',
'text-non-standard',
'text-banned-link',
]
function getFirstFailure(
context: NagContext,
fields: readonly ProjectValidationField[],
severity?: ProjectValidationFailure['severity'],
codes?: readonly ProjectTextValidationCode[],
): ProjectValidationFailure | undefined {
return context.projectValidation.failures.find(
(failure) =>
fields.includes(failure.field) &&
(!severity || failure.severity === severity) &&
(!codes || codes.includes(failure.code)),
)
}
function getFailureDescription(
context: NagContext,
fields: readonly ProjectValidationField[],
severity?: ProjectValidationFailure['severity'],
codes?: readonly ProjectTextValidationCode[],
): string {
const failure = getFirstFailure(context, fields, severity, codes)
if (!failure) return ''
const { formatMessage } = useVIntl()
return formatMessage(failure.message, failure.values)
}
function getCodedFailure(context: NagContext, code: ProjectTextValidationCode) {
return context.projectValidation.failures.find((failure) => failure.code === code)
}
function getCodedFailureDescription(context: NagContext, code: ProjectTextValidationCode) {
const failure = getCodedFailure(context, code)
if (!failure) return ''
const { formatMessage } = useVIntl()
return formatMessage(failure.message, failure.values)
}
export const projectValidationNags: Nag[] = [
{
id: 'invalid-project-name',
title: defineMessage({
id: 'nags.invalid-project-name.title',
defaultMessage: 'Fix the project name',
}),
description: (context) => getFailureDescription(context, ['name'], 'error', nameErrorCodes),
status: 'required',
shouldShow: (context) =>
getFirstFailure(context, ['name'], 'error', nameErrorCodes) !== undefined,
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-title.title',
defaultMessage: 'Edit title',
}),
shouldShow: (context) => !generalSettingsRoutes.has(context.currentRoute),
},
},
{
id: 'project-name-version',
title: defineMessage({
id: 'nags.project-name-version.title',
defaultMessage: 'Fix project name',
}),
description: (context) => getCodedFailureDescription(context, 'title-version-number'),
status: 'required',
shouldShow: (context) => getCodedFailure(context, 'title-version-number') !== undefined,
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-title.title',
defaultMessage: 'Edit title',
}),
shouldShow: (context) => !generalSettingsRoutes.has(context.currentRoute),
},
},
{
id: 'minecraft-title-clause',
title: defineMessage({
id: 'nags.minecraft-title-clause.title',
defaultMessage: 'Avoid brand infringement',
}),
description: (context) => getCodedFailureDescription(context, 'title-minecraft-branding'),
status: 'warning',
shouldShow: (context) => getCodedFailure(context, 'title-minecraft-branding') !== undefined,
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-title.title',
defaultMessage: 'Edit title',
}),
shouldShow: (context) => !generalSettingsRoutes.has(context.currentRoute),
},
},
{
id: 'invalid-project-summary',
title: defineMessage({
id: 'nags.invalid-project-summary.title',
defaultMessage: 'Fix the project summary',
}),
description: (context) =>
getFailureDescription(context, ['summary'], 'error', summaryErrorCodes),
status: 'required',
shouldShow: (context) =>
getFirstFailure(context, ['summary'], 'error', summaryErrorCodes) !== undefined,
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-summary.title',
defaultMessage: 'Edit summary',
}),
shouldShow: (context) => !generalSettingsRoutes.has(context.currentRoute),
},
},
{
id: 'project-summary-content',
title: defineMessage({
id: 'nags.project-summary-content.title',
defaultMessage: 'Review the project summary',
}),
description: (context) =>
getFailureDescription(context, ['summary'], 'error', summaryRequiredCodes),
status: 'required',
shouldShow: (context) =>
getFirstFailure(context, ['summary'], 'error', summaryRequiredCodes) !== undefined,
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-summary.title',
defaultMessage: 'Edit summary',
}),
shouldShow: (context) => !generalSettingsRoutes.has(context.currentRoute),
},
},
{
id: 'summary-too-short',
title: defineMessage({
id: 'nags.summary-too-short.title',
defaultMessage: 'Expand the summary',
}),
description: (context) => getCodedFailureDescription(context, 'summary-too-short'),
status: 'warning',
shouldShow: (context) => getCodedFailure(context, 'summary-too-short') !== undefined,
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-summary.title',
defaultMessage: 'Edit summary',
}),
shouldShow: (context) => !generalSettingsRoutes.has(context.currentRoute),
},
},
{
id: 'summary-special-formatting',
title: defineMessage({
id: 'nags.summary-special-formatting.title',
defaultMessage: 'Clean up the summary',
}),
description: (context) => getCodedFailureDescription(context, 'summary-special-formatting'),
status: 'required',
shouldShow: (context) => getCodedFailure(context, 'summary-special-formatting') !== undefined,
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-summary.title',
defaultMessage: 'Edit summary',
}),
shouldShow: (context) => !generalSettingsRoutes.has(context.currentRoute),
},
},
{
id: 'add-description',
title: defineMessage({
id: 'nags.add-description.title',
defaultMessage: 'Add a description',
}),
description: (context) => getCodedFailureDescription(context, 'description-required'),
status: 'required',
shouldShow: (context) => getCodedFailure(context, 'description-required') !== undefined,
link: {
path: 'settings/description',
title: defineMessage({
id: 'nags.settings.description.title',
defaultMessage: 'Visit description settings',
}),
shouldShow: (context) => context.currentRoute !== 'type-project-settings-description',
},
},
{
id: 'invalid-project-description',
title: defineMessage({
id: 'nags.invalid-project-description.title',
defaultMessage: 'Fix the project description',
}),
description: (context) =>
getFailureDescription(context, ['description'], 'error', descriptionErrorCodes),
status: 'required',
shouldShow: (context) =>
getFirstFailure(context, ['description'], 'error', descriptionErrorCodes) !== undefined,
link: {
path: 'settings/description',
title: defineMessage({
id: 'nags.edit-description.title',
defaultMessage: 'Edit description',
}),
shouldShow: (context) => context.currentRoute !== 'type-project-settings-description',
},
},
{
id: 'description-too-short',
title: defineMessage({
id: 'nags.description-too-short.title',
defaultMessage: 'Expand the description',
}),
description: (context) => getCodedFailureDescription(context, 'description-too-short'),
status: 'warning',
shouldShow: (context) => getCodedFailure(context, 'description-too-short') !== undefined,
link: {
path: 'settings/description',
title: defineMessage({
id: 'nags.edit-description.title',
defaultMessage: 'Edit description',
}),
shouldShow: (context) => context.currentRoute !== 'type-project-settings-description',
},
},
{
id: 'long-headers',
title: defineMessage({
id: 'nags.long-headers.title',
defaultMessage: 'Shorten headers',
}),
description: (context) => getCodedFailureDescription(context, 'description-long-headers'),
status: 'warning',
shouldShow: (context) => getCodedFailure(context, 'description-long-headers') !== undefined,
link: {
path: 'settings/description',
title: defineMessage({
id: 'nags.edit-description.title',
defaultMessage: 'Edit description',
}),
shouldShow: (context) => context.currentRoute !== 'type-project-settings-description',
},
},
{
id: 'image-heavy-description',
title: defineMessage({
id: 'nags.image-heavy-description.title',
defaultMessage: 'Ensure accessibility',
}),
description: (context) => getCodedFailureDescription(context, 'description-image-heavy'),
status: 'warning',
shouldShow: (context) => getCodedFailure(context, 'description-image-heavy') !== undefined,
link: {
path: 'settings/description',
title: defineMessage({
id: 'nags.edit-description.title',
defaultMessage: 'Edit description',
}),
shouldShow: (context) => context.currentRoute !== 'type-project-settings-description',
},
},
{
id: 'missing-alt-text',
title: defineMessage({
id: 'nags.missing-alt-text.title',
defaultMessage: 'Add image alt text',
}),
description: (context) => getCodedFailureDescription(context, 'description-missing-alt-text'),
status: 'warning',
shouldShow: (context) => getCodedFailure(context, 'description-missing-alt-text') !== undefined,
link: {
path: 'settings/description',
title: defineMessage({
id: 'nags.edit-description.title',
defaultMessage: 'Edit description',
}),
shouldShow: (context) => context.currentRoute !== 'type-project-settings-description',
},
},
{
id: 'invalid-gallery-text',
title: defineMessage({
id: 'nags.invalid-gallery-text.title',
defaultMessage: 'Fix gallery text',
}),
description: (context) =>
getFailureDescription(context, ['gallery-name', 'gallery-description'], 'error'),
status: 'required',
shouldShow: (context) =>
getFirstFailure(context, ['gallery-name', 'gallery-description'], 'error') !== undefined,
link: {
path: 'settings/gallery',
title: defineMessage({
id: 'nags.edit-gallery.title',
defaultMessage: 'Edit gallery',
}),
shouldShow: (context) => context.currentRoute !== 'type-project-settings-gallery',
},
},
]
@@ -1,184 +0,0 @@
import { defineMessage, useVIntl } from '@modrinth/ui'
import type { Nag, NagContext } from '../../types/nags'
const MAX_LANGUAGE_COUNT = 10
const ALL_LANGUAGE_COUNT = 72
export const serverProjectsNags: Nag[] = [
{
id: 'select-country',
title: defineMessage({
id: 'nags.select-country.title',
defaultMessage: 'Select a region',
}),
description: defineMessage({
id: 'nags.select-country.description',
defaultMessage: 'Let players know what region your server is located in.',
}),
status: 'required',
shouldShow: (context: NagContext) =>
!!context.projectV3?.minecraft_server && !context.projectV3?.minecraft_server.region,
link: {
path: 'settings/server',
title: defineMessage({
id: 'nags.server.title',
defaultMessage: 'Visit server settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings-server',
},
},
{
id: 'too-many-languages',
title: defineMessage({
id: 'nags.too-many-languages.title',
defaultMessage: 'Select accurate languages',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const languageCount = context.projectV3?.minecraft_server?.languages?.length || 0
const maxLanguageCount = MAX_LANGUAGE_COUNT
return formatMessage(
defineMessage({
id: 'nags.too-many-languages.description',
defaultMessage:
"You've selected {languageCount, plural, one {# language} other {# languages}}. Please list only the languages your server actively supports.",
}),
{
languageCount,
maxLanguageCount,
},
)
},
status: 'warning',
shouldShow: (context: NagContext) => {
const languageCount = context.projectV3?.minecraft_server?.languages?.length || 0
return (
languageCount > MAX_LANGUAGE_COUNT &&
//languageCount <= ALL_LANGUAGE_COUNT &&
context.projectV3?.minecraft_server != null
)
},
link: {
path: 'settings/server',
title: defineMessage({
id: 'nags.server.title',
defaultMessage: 'Visit server settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings-server',
},
},
{
id: 'all-languages',
title: defineMessage({
id: 'nags.all-languages.title',
defaultMessage: 'Select accurate languages',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const languageCount = context.projectV3?.minecraft_server?.languages?.length || 0
const allLanguageCount = ALL_LANGUAGE_COUNT
return formatMessage(
defineMessage({
id: 'nags.all-languages.description',
defaultMessage:
"You've selected all available language options. Please list only the languages your server actively supports.",
}),
{
languageCount,
allLanguageCount,
},
)
},
status: 'required',
shouldShow: (_context: NagContext) => {
// const languageCount = _context.projectV3?.minecraft_server?.languages?.length || 0
return false //languageCount >= ALL_LANGUAGE_COUNT && _context.projectV3?.minecraft_server != null
},
link: {
path: 'settings/server',
title: defineMessage({
id: 'nags.server.title',
defaultMessage: 'Visit server settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings-server',
},
},
{
id: 'select-language',
title: defineMessage({
id: 'nags.select-language.title',
defaultMessage: 'Select a language',
}),
description: defineMessage({
id: 'nags.select-language.description',
defaultMessage: 'List the language or languages supported by your server.',
}),
status: 'suggestion',
shouldShow: (context: NagContext) =>
!!context.projectV3?.minecraft_server &&
context.projectV3?.minecraft_server?.languages?.length === 0,
link: {
path: 'settings/server',
title: defineMessage({
id: 'nags.server.title',
defaultMessage: 'Visit server settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings-server',
},
},
{
id: 'add-java-address',
title: defineMessage({
id: 'nags.add-java-address.title',
defaultMessage: 'Add a Java address',
}),
description: defineMessage({
id: 'nags.add-java-address.description',
defaultMessage:
'Add the IP address and port Java Edition players can use to join your server.',
}),
status: 'required',
shouldShow: (context: NagContext) =>
!!context.projectV3?.minecraft_server && !context.projectV3?.minecraft_java_server?.address,
link: {
path: 'settings/server',
title: defineMessage({
id: 'nags.server.title',
defaultMessage: 'Visit server settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings-server',
},
},
{
id: 'select-compatibility',
title: defineMessage({
id: 'nags.select-compatibility.title',
defaultMessage: 'Select compatibility',
}),
description: defineMessage({
id: 'nags.select-compatibility.description',
defaultMessage:
'Select what versions your server supports, choose a Modpack, or upload your own.',
}),
status: 'required',
shouldShow: (context: NagContext) => {
if (
context.projectV3?.minecraft_java_server?.content?.kind === 'vanilla' &&
!context.projectV3?.minecraft_java_server?.content?.recommended_game_version
)
return true
return false
},
link: {
path: 'settings/server',
title: defineMessage({
id: 'nags.server.title',
defaultMessage: 'Visit server settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings-server',
},
},
]
-222
View File
@@ -1,222 +0,0 @@
import type { Labrinth } from '@modrinth/api-client'
import { defineMessage, formatCategory, useVIntl } from '@modrinth/ui'
import type { Nag, NagContext } from '../../types/nags'
const allResolutionTags = ['8x-', '16x', '32x', '48x', '64x', '128x', '256x', '512x+']
const MAX_TAG_COUNT = 8
const MAX_TAG_COUNT_SERVER = 18
function getCategories(
project: Labrinth.Projects.v2.Project & { actualProjectType: string },
tags: {
categories?: {
project_type: string
}[]
},
) {
return (
tags.categories?.filter(
(category: { project_type: string }) => category.project_type === project.actualProjectType,
) ?? []
)
}
export const tagsNags: Nag[] = [
{
id: 'select-tags',
title: defineMessage({
id: 'nags.select-tags.title',
defaultMessage: 'Select tags',
}),
description: defineMessage({
id: 'nags.select-tags.description',
defaultMessage:
'Select the tags that correctly apply to your project to help the right users find it.',
}),
status: 'suggestion',
shouldShow: (context: NagContext) =>
context.project.versions.length > 0 && context.project.categories.length < 1,
link: {
path: 'settings/tags',
title: defineMessage({
id: 'nags.settings.tags.title',
defaultMessage: 'Visit tag settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings-tags',
},
},
{
id: 'too-many-tags',
title: defineMessage({
id: 'nags.too-many-tags.title',
defaultMessage: 'Select accurate tags',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const tagCount =
context.project.categories.length + (context.project.additional_categories?.length || 0)
const maxTagCount = MAX_TAG_COUNT
return formatMessage(
defineMessage({
id: 'nags.too-many-tags.description',
defaultMessage:
"You've selected {tagCount, plural, one {# tag} other {# tags}}. Consider reducing to {maxTagCount} or fewer to make sure your project appears in relevant search results.",
}),
{
tagCount,
maxTagCount,
},
)
},
status: 'warning',
shouldShow: (context: NagContext) => {
if (context.projectV3?.minecraft_java_server) return false
const tagCount =
context.project.categories.length + (context.project.additional_categories?.length || 0)
return tagCount > MAX_TAG_COUNT && !context.projectV3?.minecraft_server
},
link: {
path: 'settings/tags',
title: defineMessage({
id: 'nags.edit-tags.title',
defaultMessage: 'Edit tags',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings-tags',
},
},
{
id: 'too-many-tags-server',
title: defineMessage({
id: 'nags.too-many-tags-server.title',
defaultMessage: 'Select accurate tags',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const tagCount =
context.project.categories.length + (context.project.additional_categories?.length || 0)
const maxTagCount = MAX_TAG_COUNT_SERVER
return formatMessage(
defineMessage({
id: 'nags.too-many-tags-server.description',
defaultMessage:
"You've selected {tagCount, plural, one {# tag} other {# tags}}. Please reduce to {maxTagCount} or fewer to make sure your server appears in relevant search results.",
}),
{
tagCount,
maxTagCount,
},
)
},
status: 'required',
shouldShow: (context: NagContext) => {
const tagCount =
context.project.categories.length + (context.project.additional_categories?.length || 0)
return tagCount > MAX_TAG_COUNT_SERVER && context.projectV3?.minecraft_server != null
},
link: {
path: 'settings/tags',
title: defineMessage({
id: 'nags.edit-tags.title',
defaultMessage: 'Edit tags',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings-tags',
},
},
{
id: 'multiple-resolution-tags',
title: defineMessage({
id: 'nags.multiple-resolution-tags.title',
defaultMessage: 'Select correct resolution',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const resolutionTags = context.project.categories
.concat(context.project.additional_categories)
.filter((tag: string) => allResolutionTags.includes(tag))
const sortedTags = resolutionTags.toSorted((a, b) => {
return allResolutionTags.indexOf(a) - allResolutionTags.indexOf(b)
})
return formatMessage(
defineMessage({
id: 'nags.multiple-resolution-tags.description',
defaultMessage:
"You've selected {count, plural, one {# resolution tag} other {# resolution tags}} ({tags}). Resource packs should typically only have one resolution tag that matches their primary resolution.",
}),
{
count: resolutionTags.length,
tags: sortedTags.map((tag) => formatCategory(formatMessage, tag)).join(', '),
},
)
},
status: 'warning',
shouldShow: (context: NagContext) => {
if (context.project.project_type !== 'resourcepack') return false
const resolutionTags = context.project.categories
.concat(context.project.additional_categories)
.filter((tag: string) => allResolutionTags.includes(tag))
return resolutionTags.length > 1
},
link: {
path: 'settings/tags',
title: defineMessage({
id: 'nags.edit-tags.title',
defaultMessage: 'Edit tags',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings-tags',
},
},
{
id: 'all-tags-selected',
title: defineMessage({
id: 'nags.all-tags-selected.title',
defaultMessage: 'Select accurate tags',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const categoriesForProjectType = getCategories(
context.project as Labrinth.Projects.v2.Project & { actualProjectType: string },
context.tags,
)
const totalAvailableTags = categoriesForProjectType.length
return formatMessage(
defineMessage({
id: 'nags.all-tags-selected.description',
defaultMessage:
"You've selected all {totalAvailableTags, plural, one {# available tag} other {# available tags}}. This defeats the purpose of tags, which are meant to help users find relevant projects. Please select only the tags that are relevant to your project.",
}),
{
totalAvailableTags,
},
)
},
status: 'required',
shouldShow: (context: NagContext) => {
const categoriesForProjectType = getCategories(
context.project as Labrinth.Projects.v2.Project & { actualProjectType: string },
context.tags,
)
const totalSelectedTags =
context.project.categories.length + (context.project.additional_categories?.length || 0)
return (
totalSelectedTags === categoriesForProjectType.length &&
context.project.project_type !== 'project'
)
},
link: {
path: 'settings/tags',
title: defineMessage({
id: 'nags.edit-tags.title',
defaultMessage: 'Edit tags',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings-tags',
},
},
]
+2 -4
View File
@@ -1,7 +1,6 @@
export { useStages } from './data/checklist'
export { default as keybinds } from './data/keybinds'
export { default as nags } from './data/nags'
export * from './data/nags/index'
export { getNags } from './data/nags'
export { default as attributionQuickReplies } from './data/quick-replies/permissions-quick-replies'
export { default as reportQuickReplies } from './data/quick-replies/report-quick-replies'
export {
@@ -19,8 +18,7 @@ export * from './types/quick-reply'
export * from './types/reports'
export * from './types/settings'
export * from './utils'
export * from './validation-rules/index.ts'
export * from './validators/links'
export * from './validators/non-standard-text'
export * from './validators/profanity'
export * from './validators/project-fields'
export * from './validators/project-validation'
@@ -113,12 +113,6 @@
"nags.invalid-license-url.title": {
"defaultMessage": "اضف رابط حقوق صحيح"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "يُحظر استخدام مختصرات الروابط أو طرق أخرى لإخفاء الوجهة التي قد يؤدي إليها الرابط في روابطك الخارجية أو رابط الترخيص، يرجى استخدام الروابط الطويلة المناسبة فقط."
},
"nags.link-shortener-usage.title": {
"defaultMessage": "لا تستخدم مقصر روابط"
},
"nags.long-headers.description": {
"defaultMessage": "{count, plural, one {# header} other {# headers}} في وصفك {count, plural, one {طويل جدًا} other {طويلة جدًا}}.يجب أن تكون العناوين مختصرة وتعمل كعناوين للأقسام،وليست جملًا كاملة. "
},
@@ -221,30 +215,15 @@
"nags.settings.versions.title": {
"defaultMessage": "زيارة إعدادات الإصدارات"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "لا يمكن أن يكون ملخص مشروعك نفس اسمه. من المهم أن تصنع ملخصا واضحا وغني بالمعلومات."
},
"nags.summary-same-as-title.title": {
"defaultMessage": "تاكد أن ملخصك فريد"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "لا يجب على الملخص الخاص بك أن يمتلك التنسيق، فواصل الأسطر، الأحرف الخاصة، أو الروابط، حيث أن الملخص سيعرض فقط النص العادي."
},
"nags.summary-special-formatting.title": {
"defaultMessage": "نضف الملخص"
},
"nags.summary-too-short.description": {
"defaultMessage": "الملخص الخاص بك {length, plural, one {# character} other {# characters}}. يفضل ان تكون على الاقل {minChars, plural, one {# character} other {# characters}} لإنشاء وصف واضح."
},
"nags.summary-too-short.title": {
"defaultMessage": "وسع الملخص"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "ترك اسم مشروعك نضيف يجعله قابلا للتذكر و سهلا للبحث.\nالتحديث و معلومات المحمل تعرض في مشروعك بطريقة أوتوماتيكية."
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "عدل اسم"
},
"nags.too-many-languages.description": {
"defaultMessage": "لقد حددت {languageCount, plural, one {# لغة} other {# لغات}}. يُرجى إدراج اللغات التي يدعمها خادمك بشكل فعلي فقط."
},
@@ -291,4 +270,3 @@
"defaultMessage": "زيارة إعدادات الروابط"
}
}
@@ -113,12 +113,6 @@
"nags.invalid-license-url.title": {
"defaultMessage": "Přidejte platný odkaz na licenci"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "Používání zkracovačů odkazů nebo jiných způsobů, jak zakrýt, kam může odkaz vést, je zakázáno, používejte prosím pouze vhodné odkazy v plné délce."
},
"nags.link-shortener-usage.title": {
"defaultMessage": "Nepoužívejte zkracovače odkazů"
},
"nags.long-headers.description": {
"defaultMessage": "{count, plural, one {# nadpis} other {# nadpisy}} ve vašem popisu {count, plural, one {je} other {jsou}} příliš dlouhé. Nadpisy by měly být stručné a měly by fungovat jako názvy sekcí, ne jako celé věty."
},
@@ -221,30 +215,15 @@
"nags.settings.versions.title": {
"defaultMessage": "Přejděte do nastavení verze"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "Souhrn se nesmí shodovat s názvem projektu. Je důležité vytvořit informativní a poutavý souhrn."
},
"nags.summary-same-as-title.title": {
"defaultMessage": "Vytvořte jedinečný souhrn"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "Souhrn by neměl obsahovat formátování, zalomení řádků, speciální znaky ani odkazy, protože zobrazí pouze prostý text."
},
"nags.summary-special-formatting.title": {
"defaultMessage": "Odstraňte nevhodné formátování ze souhrnu"
},
"nags.summary-too-short.description": {
"defaultMessage": "Váš popis má {length, plural, one {# čitelný znak} few {# čitelné znaky} other {# čitelných znaků}}. Pro vytvoření jasného a informativního popisu doporučujeme alespoň {minChars, plural, one {# znak} few {# znaky} other {# znaků}}."
},
"nags.summary-too-short.title": {
"defaultMessage": "Rozšířte shrnutí"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "Udržování mít jméno svého projektu čisté usnadňuje si ho zapamatovat a vyhledat. Verze a informace o modovim načítači se automaticky zobrazují vedle vašeho projektu."
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "Odstraňte technické údaje z názvu"
},
"nags.too-many-languages.description": {
"defaultMessage": "Vyberte si přesné jazyky"
},
@@ -84,4 +84,3 @@
"defaultMessage": "Besøg beskrivelse indstillinger"
}
}
@@ -113,12 +113,6 @@
"nags.invalid-license-url.title": {
"defaultMessage": "Gültigen Lizenzlink hinzufügen"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "Das Verwenden von Linkverkürzern oder anderen Methoden, welche das Ziel eines Links verschleiern, ist nicht erlaubt. Bitte verwende nur geeignete Links in voller Länge."
},
"nags.link-shortener-usage.title": {
"defaultMessage": "Verwende keine Linkverkürzer"
},
"nags.long-headers.description": {
"defaultMessage": "{count, plural, one {# Überschrift} other {# Überschriften}} in deiner Beschreibung {count, plural, one {ist} other {sind}} zu lang. Überschriften sollten kurz und prägnant sein und als Abschnittstitel dienen, nicht als vollständige Sätze."
},
@@ -221,30 +215,15 @@
"nags.settings.versions.title": {
"defaultMessage": "Versionseinstellungen ansehen"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "Deine Zusammenfassung kann nicht identisch mit deinem Projektnamen sein. Es ist wichtig, eine informative und spannende Zusammenfassung zu erstellen."
},
"nags.summary-same-as-title.title": {
"defaultMessage": "Mache die Zusammenfassung einzigartig"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "Deine Zusammenfassung sollte keine Formatierungen, Zeilenumbrüche, spezielle Zeichen oder Links enthalten, da sie nur als Klartext anzeigt wird."
},
"nags.summary-special-formatting.title": {
"defaultMessage": "Zusammenfassung bereinigen"
},
"nags.summary-too-short.description": {
"defaultMessage": "Deine Zusammenfassung ist {length, plural, one {1 Zeichen} other {# Zeichen}} lang. Mindestens {minChars, plural, one {1 Zeichen ist} other {# Zeichen sind}} empfohlen, um eine informative und spannende Zusammenfassung zu erstellen."
},
"nags.summary-too-short.title": {
"defaultMessage": "Zusammenfassung erweitern"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "Ein klarer, übersichtlicher Name macht dein Projekt einprägsamer und leichter auffindbar. Versions- und Loader-Informationen werden automatisch neben deinem Projekt angezeigt."
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "Namen bereinigen"
},
"nags.too-many-languages.description": {
"defaultMessage": "Du hast {languageCount, plural, one {# Sprache} other {# Sprachen}} ausgewählt. Bitte gib nur die Sprachen an, die dein Server aktiv unterstützt."
},
@@ -291,4 +270,3 @@
"defaultMessage": "Link-Einstellungen ansehen"
}
}
@@ -113,12 +113,6 @@
"nags.invalid-license-url.title": {
"defaultMessage": "Gültigen Lizenzlink hinzufügen"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "Die Verwendung von Linkverkürzern oder anderen Methoden, um zu verschleiern, wohin ein Link in deinen externen Links oder Lizenz-Links führt, ist verboten. Bitte nutze nur vollständige, eindeutige Links."
},
"nags.link-shortener-usage.title": {
"defaultMessage": "Verwende keine Linkverkürzer"
},
"nags.long-headers.description": {
"defaultMessage": "{count, plural, one {# Überschrift} other {# Überschriften}} in deiner Beschreibung {count, plural, one {ist} other {sind}} zu lang. Überschriften sollten kurz sein und als Abschnittstitel dienen, nicht als vollständige Sätze."
},
@@ -221,30 +215,15 @@
"nags.settings.versions.title": {
"defaultMessage": "Versionseinstellungen ansehen"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "Deine Zusammenfassung darf nicht identisch mit dem Namen deines Projekts sein. Es ist wichtig, eine informative und ansprechende Zusammenfassung zu erstellen."
},
"nags.summary-same-as-title.title": {
"defaultMessage": "Mach die Zusammenfassung einzigartig"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "Deine Zusammenfassung sollte keine Formatierungen, Zeilenumbrüche, Sonderzeichen oder Links enthalten, da die Zusammenfassung nur als unformatierter Text angezeigt wird."
},
"nags.summary-special-formatting.title": {
"defaultMessage": "Zusammenfassung bereinigen"
},
"nags.summary-too-short.description": {
"defaultMessage": "Deine Zusammenfassung enthält {length, plural, one {# Zeichen} other {# Zeichen}}. Es wird empfohlen, mindestens {minChars, plural, one {# Zeichen} other {# Zeichen}} zu verwenden, um eine informative und ansprechende Zusammenfassung zu erstellen."
},
"nags.summary-too-short.title": {
"defaultMessage": "Zusammenfassung erweitern"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "Ein klarer, übersichtlicher Name macht dein Projekt einprägsamer und leichter auffindbar. Versions- und Loader-Informationen werden automatisch neben deinem Projekt angezeigt."
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "Namen bereinigen"
},
"nags.too-many-languages.description": {
"defaultMessage": "Du hast {languageCount, plural, one {# Sprache} other {# Sprachen}} ausgewählt. Bitte gib nur die Sprachen an, die dein Server aktiv unterstützt."
},
@@ -291,4 +270,3 @@
"defaultMessage": "Linkeinstellungen ansehen"
}
}
+405 -378
View File
@@ -1,380 +1,407 @@
{
"nags.add-description.description": {
"defaultMessage": "A description that clearly describes the project's purpose and function is required."
},
"nags.add-description.title": {
"defaultMessage": "Add a description"
},
"nags.add-icon.description": {
"defaultMessage": "Adding a unique, relevant, and engaging icon makes your project identifiable and helps it stand out."
},
"nags.add-icon.title": {
"defaultMessage": "Add an icon"
},
"nags.add-java-address.description": {
"defaultMessage": "Add the IP address and port Java Edition players can use to join your server."
},
"nags.add-java-address.title": {
"defaultMessage": "Add a Java address"
},
"nags.add-license-details.description": {
"defaultMessage": "Add a valid URL and name or SPDX identifier for your custom license."
},
"nags.add-license-details.title": {
"defaultMessage": "Add license details"
},
"nags.add-links-server.description": {
"defaultMessage": "Add any relevant links targeted outside of Modrinth, such as a website, store, or a Discord invite."
},
"nags.add-links-server.title": {
"defaultMessage": "Add external links"
},
"nags.add-links.description": {
"defaultMessage": "Add any relevant links targeted outside of Modrinth, such as source code, an issue tracker, or a Discord invite."
},
"nags.add-links.title": {
"defaultMessage": "Add external links"
},
"nags.all-languages.description": {
"defaultMessage": "You've selected all available language options. Please list only the languages your server actively supports."
},
"nags.all-languages.title": {
"defaultMessage": "Select accurate languages"
},
"nags.all-tags-selected.description": {
"defaultMessage": "You've selected all {totalAvailableTags, plural, one {# available tag} other {# available tags}}. This defeats the purpose of tags, which are meant to help users find relevant projects. Please select only the tags that are relevant to your project."
},
"nags.all-tags-selected.title": {
"defaultMessage": "Select accurate tags"
},
"nags.banned-link-usage.description": {
"defaultMessage": "“{url}” is not allowed in project links."
},
"nags.banned-link-usage.title": {
"defaultMessage": "Remove prohibited links"
},
"nags.check-disclosures.description": {
"defaultMessage": "Make sure users are aware of any important details by filling in content disclosures that apply to your {type}."
},
"nags.check-disclosures.title": {
"defaultMessage": "Check content disclosures"
},
"nags.description-too-short.description": {
"defaultMessage": "Your description is {length, plural, one {# readable character} other {# readable characters}}. At least {minChars, plural, one {# character} other {# characters}} is recommended to create a clear and informative description."
},
"nags.description-too-short.title": {
"defaultMessage": "Expand the description"
},
"nags.edit-description.title": {
"defaultMessage": "Edit description"
},
"nags.edit-gallery.title": {
"defaultMessage": "Edit gallery"
},
"nags.edit-license.title": {
"defaultMessage": "Edit license"
},
"nags.edit-summary.title": {
"defaultMessage": "Edit summary"
},
"nags.edit-tags.title": {
"defaultMessage": "Edit tags"
},
"nags.edit-title.title": {
"defaultMessage": "Edit title"
},
"nags.feature-gallery-image.description": {
"defaultMessage": "The featured gallery image is often how your project makes its first impression."
},
"nags.feature-gallery-image.title": {
"defaultMessage": "Feature a gallery image"
},
"nags.gallery.title": {
"defaultMessage": "Visit gallery page"
},
"nags.gpl-license-source-required.description": {
"defaultMessage": "Your {type} uses a license which requires source code to be available. Please provide a source code link or sources file for each additional version, or consider using a different license."
},
"nags.gpl-license-source-required.title": {
"defaultMessage": "Provide source code"
},
"nags.identical-links.description": {
"defaultMessage": "Some of your external links appear to be identical. Each link should be entered only once and with the appropriate link type."
},
"nags.identical-links.title": {
"defaultMessage": "Clean up identical links"
},
"nags.image-heavy-description.description": {
"defaultMessage": "Your Description should contain sufficient plain text or image alt-text, keeping it accessible to those using screen readers or with slow internet connections."
},
"nags.image-heavy-description.title": {
"defaultMessage": "Ensure accessibility"
},
"nags.invalid-gallery-text.title": {
"defaultMessage": "Fix gallery text"
},
"nags.invalid-license-url.description.default": {
"defaultMessage": "License URL is invalid."
},
"nags.invalid-license-url.description.domain": {
"defaultMessage": "Your license URL points to {domain}, which is not appropriate for license information. License URLs should link directly to your license file, not social media, gaming platforms, etc."
},
"nags.invalid-license-url.description.malformed": {
"defaultMessage": "Your license URL appears to be malformed. Please provide a valid URL to your license text."
},
"nags.invalid-license-url.title": {
"defaultMessage": "Add a valid license link"
},
"nags.invalid-project-description.title": {
"defaultMessage": "Fix the project description"
},
"nags.invalid-project-name.title": {
"defaultMessage": "Fix the project name"
},
"nags.invalid-project-summary.title": {
"defaultMessage": "Fix the project summary"
},
"nags.link.description.invalid-url": {
"defaultMessage": "The description has an invalid link"
},
"nags.link.discord.channel": {
"defaultMessage": "This is a link to a Discord channel, not a server invite."
},
"nags.link.discord.invite.expires": {
"defaultMessage": "This Discord invite is set to expire"
},
"nags.link.discord.invite.invalid": {
"defaultMessage": "This Discord invite is invalid or has expired."
},
"nags.link.discord.invite.not-guild": {
"defaultMessage": "This Discord invite does not lead to a server."
},
"nags.link.discord.user": {
"defaultMessage": "This is a link to a Discord user, not a server invite."
},
"nags.link.expected-type": {
"defaultMessage": "This isn't a valid {label} link."
},
"nags.link.git.archived": {
"defaultMessage": "This repository is archived, which disables issues."
},
"nags.link.git.empty": {
"defaultMessage": "This repository appears to be empty."
},
"nags.link.git.issues-disabled": {
"defaultMessage": "Issues are disabled on this repository."
},
"nags.link.git.not-found": {
"defaultMessage": "This repository could not be found (it may be private or deleted)."
},
"nags.link.git.wiki-disabled": {
"defaultMessage": "The wiki is disabled on this repository."
},
"nags.link.invalid-url": {
"defaultMessage": "This URL is invalid"
},
"nags.link.license.url-mismatch": {
"defaultMessage": "This link points to the {detected} license, but your project is set to {selected}."
},
"nags.link.license.url-redundant": {
"defaultMessage": "You don't need to link to a generic license page for a supported license — consider linking to your repository's own license file instead, or leaving this blank."
},
"nags.link.never-valid": {
"defaultMessage": "{label} links aren't allowed here."
},
"nags.link.unverifiable-redirect": {
"defaultMessage": "This doesn't look like a {platform} link."
},
"nags.link.wrong-field": {
"defaultMessage": "{label} links aren't valid for this field."
},
"nags.link.youtube.unrecognized": {
"defaultMessage": "This doesn't look like a YouTube donation link."
},
"nags.long-headers.description": {
"defaultMessage": "{count, plural, one {# header} other {# headers}} in your description {count, plural, one {is} other {are}} too long. Headers should be concise and act as section titles, not full sentences."
},
"nags.long-headers.title": {
"defaultMessage": "Shorten headers"
},
"nags.minecraft-title-clause.description": {
"defaultMessage": "Projects must not use Minecraft's branding or include \"Minecraft\" as a significant part of the name."
},
"nags.minecraft-title-clause.title": {
"defaultMessage": "Avoid brand infringement"
},
"nags.missing-alt-text.description": {
"defaultMessage": "Some of your images are missing alt text, which is important for accessibility, especially for visually impaired users."
},
"nags.missing-alt-text.title": {
"defaultMessage": "Add image alt text"
},
"nags.misused-discord-link-description": {
"defaultMessage": "Discord invites can not be used for other link types. Please put your Discord link in the Discord Invite link field only."
},
"nags.misused-discord-link.title": {
"defaultMessage": "Move Discord invite"
},
"nags.moderation.title": {
"defaultMessage": "Visit moderation thread"
},
"nags.moderator-feedback.description": {
"defaultMessage": "Review and address all concerns from the moderation team before resubmitting."
},
"nags.moderator-feedback.title": {
"defaultMessage": "Review feedback"
},
"nags.multiple-resolution-tags.description": {
"defaultMessage": "You've selected {count, plural, one {# resolution tag} other {# resolution tags}} ({tags}). Resource packs should typically only have one resolution tag that matches their primary resolution."
},
"nags.multiple-resolution-tags.title": {
"defaultMessage": "Select correct resolution"
},
"nags.project-name-version.title": {
"defaultMessage": "Fix project name"
},
"nags.project-summary-content.title": {
"defaultMessage": "Review the project summary"
},
"nags.review-permissions.description": {
"defaultMessage": "Make sure you have provided proof of your permission to distribute any external content in your Modpack."
},
"nags.review-permissions.title": {
"defaultMessage": "Review external permissions"
},
"nags.select-compatibility.description": {
"defaultMessage": "Select what versions your server supports, choose a Modpack, or upload your own."
},
"nags.select-compatibility.title": {
"defaultMessage": "Select compatibility"
},
"nags.select-country.description": {
"defaultMessage": "Let players know what region your server is located in."
},
"nags.select-country.title": {
"defaultMessage": "Select a region"
},
"nags.select-language.description": {
"defaultMessage": "List the language or languages supported by your server."
},
"nags.select-language.title": {
"defaultMessage": "Select a language"
},
"nags.select-license.description": {
"defaultMessage": "Select the license your {type} is distributed under."
},
"nags.select-license.title": {
"defaultMessage": "Select a license"
},
"nags.select-tags.description": {
"defaultMessage": "Select the tags that correctly apply to your project to help the right users find it."
},
"nags.select-tags.title": {
"defaultMessage": "Select tags"
},
"nags.server.title": {
"defaultMessage": "Visit server settings"
},
"nags.settings.description.title": {
"defaultMessage": "Visit description settings"
},
"nags.settings.disclosures.title": {
"defaultMessage": "Visit disclosure settings"
},
"nags.settings.license.title": {
"defaultMessage": "Visit license settings"
},
"nags.settings.links.title": {
"defaultMessage": "Visit links settings"
},
"nags.settings.permissions.title": {
"defaultMessage": "Visit permissions dashboard"
},
"nags.settings.tags.title": {
"defaultMessage": "Visit tag settings"
},
"nags.settings.title": {
"defaultMessage": "Visit general settings"
},
"nags.settings.versions.title": {
"defaultMessage": "Visit versions settings"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "Your summary should not contain formatting, line breaks, special characters, or links. The summary only displays plain text."
},
"nags.summary-special-formatting.title": {
"defaultMessage": "Clean up the summary"
},
"nags.summary-too-short.title": {
"defaultMessage": "Expand the summary"
},
"nags.too-many-languages.description": {
"defaultMessage": "You've selected {languageCount, plural, one {# language} other {# languages}}. Please list only the languages your server actively supports."
},
"nags.too-many-languages.title": {
"defaultMessage": "Select accurate languages"
},
"nags.too-many-tags-server.description": {
"defaultMessage": "You've selected {tagCount, plural, one {# tag} other {# tags}}. Please reduce to {maxTagCount} or fewer to make sure your server appears in relevant search results."
},
"nags.too-many-tags-server.title": {
"defaultMessage": "Select accurate tags"
},
"nags.too-many-tags.description": {
"defaultMessage": "You've selected {tagCount, plural, one {# tag} other {# tags}}. Consider reducing to {maxTagCount} or fewer to make sure your project appears in relevant search results."
},
"nags.too-many-tags.title": {
"defaultMessage": "Select accurate tags"
},
"nags.upload-gallery-image.description": {
"defaultMessage": "At least one gallery image is required to showcase the content of your {type}."
},
"nags.upload-gallery-image.description-resourcepack": {
"defaultMessage": "At least one gallery image is required to showcase the content of your resource pack, except for audio or localization packs. If this describes your pack, please select the appropriate tag."
},
"nags.upload-gallery-image.description-shader": {
"defaultMessage": "At least three gallery images are required to showcase the content of your shader in a variety of situations and conditions."
},
"nags.upload-gallery-image.title": {
"defaultMessage": "Upload a gallery image"
},
"nags.upload-version.description": {
"defaultMessage": "At least one version is required for a project to be submitted for review."
},
"nags.upload-version.title": {
"defaultMessage": "Upload a version"
},
"nags.verify-external-links.description": {
"defaultMessage": "Some of your external links may be using domains that are inappropriate for that type of link."
},
"nags.verify-external-links.title": {
"defaultMessage": "Verify external links"
},
"nags.visit-links-settings.title": {
"defaultMessage": "Visit links settings"
},
"project.text-validation.banned-link": {
"defaultMessage": "“{url}” is not allowed in project summaries or descriptions."
},
"project.text-validation.description-profanity": {
"defaultMessage": "Excessive profanity is not allowed. Detected: {values}"
},
"project.text-validation.non-standard-text": {
"defaultMessage": "Non-standard text characters are not allowed."
},
"project.text-validation.profanity": {
"defaultMessage": "The detected profanity “{value}” is not allowed."
},
"project.text-validation.slur": {
"defaultMessage": "The detected slur “{value}” is not allowed."
},
"project.text-validation.summary-matches-title": {
"defaultMessage": "A project summary cannot be the same as it's title."
},
"project.text-validation.summary-too-short": {
"defaultMessage": "Your summary is {length, plural, one {# character} other {# characters}}. At least {minChars, plural, one {# character} other {# characters}} is recommended to create an informative and enticing summary."
},
"project.text-validation.title-version-number": {
"defaultMessage": "Names are not allowed to include version numbers."
}
"nags.add-description.description": {
"defaultMessage": "A description that clearly describes the project's purpose and function is required."
},
"nags.add-description.title": {
"defaultMessage": "Add a description"
},
"nags.add-icon.description": {
"defaultMessage": "Adding a unique, relevant, and engaging icon makes your project identifiable and helps it stand out."
},
"nags.add-icon.title": {
"defaultMessage": "Add an icon"
},
"nags.add-java-address.description": {
"defaultMessage": "Add the IP address and port Java Edition players can use to join your server."
},
"nags.add-java-address.title": {
"defaultMessage": "Add a Java address"
},
"nags.add-license-details.description": {
"defaultMessage": "Add a valid URL and name or SPDX identifier for your custom license."
},
"nags.add-license-details.title": {
"defaultMessage": "Add license details"
},
"nags.add-links-server.description": {
"defaultMessage": "Add any relevant links targeted outside of Modrinth, such as a website, store, or a Discord invite."
},
"nags.add-links-server.title": {
"defaultMessage": "Add external links"
},
"nags.add-links.description": {
"defaultMessage": "Add any relevant links targeted outside of Modrinth, such as source code, an issue tracker, or a Discord invite."
},
"nags.add-links.title": {
"defaultMessage": "Add external links"
},
"nags.all-languages.description": {
"defaultMessage": "You've selected all available language options. Please list only the languages your server actively supports."
},
"nags.all-languages.title": {
"defaultMessage": "Select accurate languages"
},
"nags.all-tags-selected.description": {
"defaultMessage": "You've selected all {totalAvailableTags, plural, one {# available tag} other {# available tags}}. This defeats the purpose of tags, which are meant to help users find relevant projects. Please select only the tags that are relevant to your project."
},
"nags.all-tags-selected.title": {
"defaultMessage": "Select accurate tags"
},
"nags.banned-link-usage.description": {
"defaultMessage": "“{url}” is not allowed in project links."
},
"nags.banned-link-usage.title": {
"defaultMessage": "Remove prohibited links"
},
"nags.check-disclosures.description": {
"defaultMessage": "Make sure users are aware of any important details by filling in content disclosures that apply to your {type}."
},
"nags.check-disclosures.title": {
"defaultMessage": "Check content disclosures"
},
"nags.description-too-short.description": {
"defaultMessage": "Your description is {length, plural, one {# readable character} other {# readable characters}}. At least {minChars, plural, one {# character} other {# characters}} is recommended to create a clear and informative description."
},
"nags.description-too-short.title": {
"defaultMessage": "Expand the description"
},
"nags.edit-description.title": {
"defaultMessage": "Edit description"
},
"nags.edit-gallery.title": {
"defaultMessage": "Edit gallery"
},
"nags.edit-license.title": {
"defaultMessage": "Edit license"
},
"nags.edit-summary.title": {
"defaultMessage": "Edit summary"
},
"nags.edit-tags.title": {
"defaultMessage": "Edit tags"
},
"nags.edit-title.title": {
"defaultMessage": "Edit title"
},
"nags.feature-gallery-image.description": {
"defaultMessage": "The featured gallery image is often how your project makes its first impression."
},
"nags.feature-gallery-image.title": {
"defaultMessage": "Feature a gallery image"
},
"nags.gallery-text-non-standard.description": {
"defaultMessage": "Non-standard text characters, such as “₮ɆӾ₮”, are not allowed."
},
"nags.gallery-text-profanity.description": {
"defaultMessage": "Your gallery cannot contain excessive profanity. Detected: “{value}”."
},
"nags.gallery-text-slur.description": {
"defaultMessage": "Your gallery cannot contain any slurs. Detected: “{value}”."
},
"nags.gallery.title": {
"defaultMessage": "Visit gallery page"
},
"nags.gpl-license-source-required.description": {
"defaultMessage": "Your {type} uses a license which requires source code to be available. Please provide a source code link or sources file for each additional version, or consider using a different license."
},
"nags.gpl-license-source-required.title": {
"defaultMessage": "Provide source code"
},
"nags.identical-links.description": {
"defaultMessage": "Some of your external links appear to be identical. Each link should be entered only once and with the appropriate link type."
},
"nags.identical-links.title": {
"defaultMessage": "Clean up identical links"
},
"nags.image-heavy-description.description": {
"defaultMessage": "Your Description should contain sufficient plain text or image alt-text, keeping it accessible to those using screen readers or with slow internet connections."
},
"nags.image-heavy-description.title": {
"defaultMessage": "Ensure accessibility"
},
"nags.invalid-gallery-text.title": {
"defaultMessage": "Fix gallery text"
},
"nags.invalid-license-url.description.default": {
"defaultMessage": "License URL is invalid."
},
"nags.invalid-license-url.description.domain": {
"defaultMessage": "Your license URL points to {domain}, which is not appropriate for license information. License URLs should link directly to your license file, not social media, gaming platforms, etc."
},
"nags.invalid-license-url.description.malformed": {
"defaultMessage": "Your license URL appears to be malformed. Please provide a valid URL to your license text."
},
"nags.invalid-license-url.title": {
"defaultMessage": "Add a valid license link"
},
"nags.invalid-project-description.title": {
"defaultMessage": "Fix the project description"
},
"nags.invalid-project-name.title": {
"defaultMessage": "Fix the project name"
},
"nags.invalid-project-summary.title": {
"defaultMessage": "Fix the project summary"
},
"nags.link.description.invalid-url": {
"defaultMessage": "The description has an invalid link"
},
"nags.link.discord.channel": {
"defaultMessage": "This is a link to a Discord channel, not a server invite."
},
"nags.link.discord.invite.expires": {
"defaultMessage": "This Discord invite is set to expire"
},
"nags.link.discord.invite.invalid": {
"defaultMessage": "This Discord invite is invalid or has expired."
},
"nags.link.discord.invite.not-guild": {
"defaultMessage": "This Discord invite does not lead to a server."
},
"nags.link.discord.user": {
"defaultMessage": "This is a link to a Discord user, not a server invite."
},
"nags.link.expected-type": {
"defaultMessage": "This isn't a valid {label} link."
},
"nags.link.git.archived": {
"defaultMessage": "This repository is archived, which disables issues."
},
"nags.link.git.empty": {
"defaultMessage": "This repository appears to be empty."
},
"nags.link.git.issues-disabled": {
"defaultMessage": "Issues are disabled on this repository."
},
"nags.link.git.not-found": {
"defaultMessage": "This repository could not be found (it may be private or deleted)."
},
"nags.link.git.wiki-disabled": {
"defaultMessage": "The wiki is disabled on this repository."
},
"nags.link.invalid-url": {
"defaultMessage": "This URL is invalid"
},
"nags.link.license.url-mismatch": {
"defaultMessage": "This link points to the {detected} license, but your project is set to {selected}."
},
"nags.link.license.url-redundant": {
"defaultMessage": "You don't need to link to a generic license page for a supported license — consider linking to your repository's own license file instead, or leaving this blank."
},
"nags.link.never-valid": {
"defaultMessage": "{label} links aren't allowed here."
},
"nags.link.unverifiable-redirect": {
"defaultMessage": "This doesn't look like a {platform} link."
},
"nags.link.wrong-field": {
"defaultMessage": "{label} links aren't valid for this field."
},
"nags.link.youtube.unrecognized": {
"defaultMessage": "This doesn't look like a YouTube donation link."
},
"nags.long-headers.description": {
"defaultMessage": "{count, plural, one {# header} other {# headers}} in your description {count, plural, one {is} other {are}} too long. Headers should be concise and act as section titles, not full sentences."
},
"nags.long-headers.title": {
"defaultMessage": "Shorten headers"
},
"nags.minecraft-title-clause.description": {
"defaultMessage": "Projects must not use Minecraft's branding or include \"Minecraft\" as a significant part of the name."
},
"nags.minecraft-title-clause.title": {
"defaultMessage": "Avoid brand infringement"
},
"nags.missing-alt-text.description": {
"defaultMessage": "Some of your images are missing alt text, which is important for accessibility, especially for visually impaired users."
},
"nags.missing-alt-text.title": {
"defaultMessage": "Add image alt text"
},
"nags.misused-discord-link-description": {
"defaultMessage": "Discord invites can not be used for other link types. Please put your Discord link in the Discord Invite link field only."
},
"nags.misused-discord-link.title": {
"defaultMessage": "Move Discord invite"
},
"nags.moderation.title": {
"defaultMessage": "Visit moderation thread"
},
"nags.moderator-feedback.description": {
"defaultMessage": "Review and address all concerns from the moderation team before resubmitting."
},
"nags.moderator-feedback.title": {
"defaultMessage": "Review feedback"
},
"nags.multiple-resolution-tags.description": {
"defaultMessage": "You've selected {count, plural, one {# resolution tag} other {# resolution tags}} ({tags}). Resource packs should typically only have one resolution tag that matches their primary resolution."
},
"nags.multiple-resolution-tags.title": {
"defaultMessage": "Select correct resolution"
},
"nags.project-description-banned-link.description": {
"defaultMessage": "“{fullUrl}” is not allowed in project descriptions."
},
"nags.project-description-non-standard-text.description": {
"defaultMessage": "Non-standard text characters, such as “₮ɆӾ₮”, are not allowed."
},
"nags.project-description-profanity.description": {
"defaultMessage": "Your project cannot contain excessive profanity. Detected: “{value}”."
},
"nags.project-description-slur.description": {
"defaultMessage": "Your project cannot contain any slurs. Detected: “{value}”."
},
"nags.project-name-non-standard-text.description": {
"defaultMessage": "Non-standard text characters, such as “₮ɆӾ₮”, are not allowed."
},
"nags.project-name-profanity.description": {
"defaultMessage": "Your project name cannot contain profanity. Detected: “{value}”."
},
"nags.project-name-slur.description": {
"defaultMessage": "Your project name cannot contain any slurs. Detected: “{value}”."
},
"nags.project-name-version.title": {
"defaultMessage": "Fix project name"
},
"nags.project-summary-banned-link.description": {
"defaultMessage": "“{fullUrl}” is not allowed in project summaries."
},
"nags.project-summary-content.title": {
"defaultMessage": "Review the project summary"
},
"nags.project-summary-non-standard-text.description": {
"defaultMessage": "Non-standard text characters, such as “₮ɆӾ₮”, are not allowed."
},
"nags.project-summary-profanity.description": {
"defaultMessage": "Your project summary cannot contain profanity. Detected: “{value}”."
},
"nags.project-summary-slur.description": {
"defaultMessage": "Your project summary cannot contain any slurs. Detected: “{value}”."
},
"nags.review-permissions.description": {
"defaultMessage": "Make sure you have provided proof of your permission to distribute any external content in your Modpack."
},
"nags.review-permissions.title": {
"defaultMessage": "Review external permissions"
},
"nags.select-compatibility.description": {
"defaultMessage": "Select what versions your server supports, choose a Modpack, or upload your own."
},
"nags.select-compatibility.title": {
"defaultMessage": "Select compatibility"
},
"nags.select-country.description": {
"defaultMessage": "Let players know what region your server is located in."
},
"nags.select-country.title": {
"defaultMessage": "Select a region"
},
"nags.select-language.description": {
"defaultMessage": "List the language or languages supported by your server."
},
"nags.select-language.title": {
"defaultMessage": "Select a language"
},
"nags.select-license.description": {
"defaultMessage": "Select the license your {type} is distributed under."
},
"nags.select-license.title": {
"defaultMessage": "Select a license"
},
"nags.select-tags.description": {
"defaultMessage": "Select the tags that correctly apply to your project to help the right users find it."
},
"nags.select-tags.title": {
"defaultMessage": "Select tags"
},
"nags.server.title": {
"defaultMessage": "Visit server settings"
},
"nags.settings.description.title": {
"defaultMessage": "Visit description settings"
},
"nags.settings.disclosures.title": {
"defaultMessage": "Visit disclosure settings"
},
"nags.settings.license.title": {
"defaultMessage": "Visit license settings"
},
"nags.settings.links.title": {
"defaultMessage": "Visit links settings"
},
"nags.settings.permissions.title": {
"defaultMessage": "Visit permissions dashboard"
},
"nags.settings.tags.title": {
"defaultMessage": "Visit tag settings"
},
"nags.settings.title": {
"defaultMessage": "Visit general settings"
},
"nags.settings.versions.title": {
"defaultMessage": "Visit versions settings"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "Your summary should not contain formatting, line breaks, special characters, or links. The summary only displays plain text."
},
"nags.summary-special-formatting.title": {
"defaultMessage": "Clean up the summary"
},
"nags.summary-too-short.title": {
"defaultMessage": "Expand the summary"
},
"nags.too-many-languages.description": {
"defaultMessage": "You've selected {languageCount, plural, one {# language} other {# languages}}. Please list only the languages your server actively supports."
},
"nags.too-many-languages.title": {
"defaultMessage": "Select accurate languages"
},
"nags.too-many-tags-server.description": {
"defaultMessage": "You've selected {tagCount, plural, one {# tag} other {# tags}}. Please reduce to {maxTagCount} or fewer to make sure your server appears in relevant search results."
},
"nags.too-many-tags-server.title": {
"defaultMessage": "Select accurate tags"
},
"nags.too-many-tags.description": {
"defaultMessage": "You've selected {tagCount, plural, one {# tag} other {# tags}}. Consider reducing to {maxTagCount} or fewer to make sure your project appears in relevant search results."
},
"nags.too-many-tags.title": {
"defaultMessage": "Select accurate tags"
},
"nags.upload-gallery-image.description": {
"defaultMessage": "At least one gallery image is required to showcase the content of your {type}."
},
"nags.upload-gallery-image.description-resourcepack": {
"defaultMessage": "At least one gallery image is required to showcase the content of your resource pack, except for audio or localization packs. If this describes your pack, please select the appropriate tag."
},
"nags.upload-gallery-image.description-shader": {
"defaultMessage": "At least three gallery images are required to showcase the content of your shader in a variety of situations and conditions."
},
"nags.upload-gallery-image.title": {
"defaultMessage": "Upload a gallery image"
},
"nags.upload-version.description": {
"defaultMessage": "At least one version is required for a project to be submitted for review."
},
"nags.upload-version.title": {
"defaultMessage": "Upload a version"
},
"nags.verify-external-links.description": {
"defaultMessage": "Some of your external links may be using domains that are inappropriate for that type of link."
},
"nags.verify-external-links.title": {
"defaultMessage": "Verify external links"
},
"nags.visit-links-settings.title": {
"defaultMessage": "Visit links settings"
},
"project.text-validation.summary-matches-title": {
"defaultMessage": "A project summary cannot be the same as it's title."
},
"project.text-validation.summary-too-short": {
"defaultMessage": "Your summary is {length, plural, one {# character} other {# characters}}. At least {minChars, plural, one {# character} other {# characters}} is recommended to create an informative and enticing summary."
},
"project.text-validation.title-version-number": {
"defaultMessage": "Names are not allowed to include version numbers."
}
}
@@ -113,12 +113,6 @@
"nags.invalid-license-url.title": {
"defaultMessage": "Añade un enlace de licencia válido"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "Se prohíbe el uso de acortadores de enlaces u otros métodos para ocultar el destino de tus enlaces externos o de licencia. Usa únicamente enlaces completos."
},
"nags.link-shortener-usage.title": {
"defaultMessage": "No uses acortadores de enlaces"
},
"nags.long-headers.description": {
"defaultMessage": "{count, plural, one {# encabezado} other {# encabezados}} en tu descripción {count, plural, one {es} other {son}} demasiado {count, plural, one {largo} other {largos}}. Los encabezados deben ser concisos y actuar como títulos de sección, no como frases completas."
},
@@ -221,30 +215,15 @@
"nags.settings.versions.title": {
"defaultMessage": "Visitar la configuración de versiones"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "Tu resumen no puede ser igual al nombre de tu proyecto. Es importante crear un resumen informativo y llamativo."
},
"nags.summary-same-as-title.title": {
"defaultMessage": "Crea un resumen único"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "Tu resumen no debe contener formato, saltos de línea, caracteres especiales ni enlaces, ya que solo se mostrará texto plano."
},
"nags.summary-special-formatting.title": {
"defaultMessage": "Aclara el resumen"
},
"nags.summary-too-short.description": {
"defaultMessage": "Tu resumen tiene {length, plural, one {# carácter} other {# caracteres}}. Se recomienda al menos {minChars, plural, one {# carácter} other {# caracteres}} para crear un resumen informativo y atractivo."
},
"nags.summary-too-short.title": {
"defaultMessage": "Ampliar el resumen"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "Mantener el nombre de tu proyecto limpio hace que sea más fácil de recordar y de encontrar. La información de la versión y del cargador se muestra automáticamente junto a tu proyecto."
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "Hacer el nombre más claro"
},
"nags.too-many-languages.description": {
"defaultMessage": "Has seleccionado {languageCount, plural, one {# idioma} other {# idiomas}}. Por favor, indica solo los idiomas que tu servidor admite activamente."
},
@@ -291,4 +270,3 @@
"defaultMessage": "Ver configuración de enlaces"
}
}
@@ -113,12 +113,6 @@
"nags.invalid-license-url.title": {
"defaultMessage": "Añadir un enlace de licencia válido"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "Se prohíbe el uso de acortadores de enlaces u otros métodos para ocultar el destino de los enlaces externos o los enlaces de licencia. Utilice únicamente enlaces completos adecuados."
},
"nags.link-shortener-usage.title": {
"defaultMessage": "No utilices acortadores de enlaces"
},
"nags.long-headers.description": {
"defaultMessage": "{count, plural, one {# header} other {# headers}} en tu descripción {count, plural, one {es} other {son}} demasiado largo. Los encabezados deben ser concisos y actuar como títulos de sección, no como oraciones completas."
},
@@ -221,30 +215,15 @@
"nags.settings.versions.title": {
"defaultMessage": "Visita los ajustes de versiones"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "Tu resumen no puede ser igual al Nombre de tu proyecto. Es importante crear un Resumen informativo y atractivo."
},
"nags.summary-same-as-title.title": {
"defaultMessage": "Haz que el resumen sea único"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "Tu resumen no debe contener formato, saltos de línea, caracteres especiales ni enlaces, ya que solo se mostrará texto sin formato."
},
"nags.summary-special-formatting.title": {
"defaultMessage": "Aclara el resumen"
},
"nags.summary-too-short.description": {
"defaultMessage": "Tu resumen tiene {length, plural, one {# carácter} other {# caracteres}}. Se recomienda un mínimo de {minChars, plural, one {# carácter} other {# caracteres}} para crear un resumen informativo y atractivo."
},
"nags.summary-too-short.title": {
"defaultMessage": "Expandir el resumen"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "Mantener limpio el nombre de tu proyecto lo hace más memorable y fácil de encontrar. La información sobre la versión y el loader se muestra automáticamente junto a tu proyecto."
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "Limpiar el nombre"
},
"nags.too-many-languages.description": {
"defaultMessage": "Has seleccionado {languageCount, plural, one {# language} other {# languages}}. Por favor elige solo a los que tu servidor de soporte activamente."
},
@@ -291,4 +270,3 @@
"defaultMessage": "Visitar la configuración de enlaces"
}
}
@@ -74,12 +74,6 @@
"nags.invalid-license-url.title": {
"defaultMessage": "Lisää kelvollinen lisenssilinkki"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "Linkinlyhenteiden tai muiden linkin kohteen piilottavien menetelmien käyttö ulkoisissa linkeissä on kiellettyä. Ole hyvä ja käytä suoria ja täysmittaisia linkkejä."
},
"nags.link-shortener-usage.title": {
"defaultMessage": "Älä käytä linkinlyhenteitä"
},
"nags.long-headers.description": {
"defaultMessage": "{count, plural, one {# otsikko} other {# otsikot}} kuvauksessasi {count, plural,one {on liian pitkä} other {ovat liian pitkiä}}. Otsikoiden tulisi olla tiiviitä ja toimia osioiden otsikkoina, eikä kokonaisina virkkeinä."
},
@@ -123,4 +117,3 @@
"defaultMessage": "Vahvista ulkoiset linkit"
}
}
@@ -95,12 +95,6 @@
"nags.invalid-license-url.title": {
"defaultMessage": "Maglagay ng wastong link ng lisensiya"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "Ang paggamit ng pangikli ng link o iba pang paraan ng paglilihid ng link na maaaring tumuro sa link palabas o link ng lisensiya ay pinagbabawal, mangyaring gumamit lamang ng mga buong-ganapang taas na link."
},
"nags.link-shortener-usage.title": {
"defaultMessage": "Huwag gumamit ng pangikli ng link"
},
"nags.long-headers.description": {
"defaultMessage": "Mayroon kang {count, plural, one {# uluhan} other {# na uluhan}} na mataas sa iyong paglalarawan. Kadalasang maikli lamang ang mga uluhan bilang pamagat ng mga pangkat, hindi mga buong pangungusap."
},
@@ -188,30 +182,15 @@
"nags.settings.title": {
"defaultMessage": "Bisitahin ang mga general na setting"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "Hindi maaaring magkapareho ang iyong buod sa Pangalan ng iyong proyekto. Mahalagang gumawa ng isang nagbibigay-kaalaman at nakakaakit-akit na Buod."
},
"nags.summary-same-as-title.title": {
"defaultMessage": "Gawing katangi-tangi ang buod"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "Ang iyong buid ay hindi dapat magtaglay ng formatting, line break, espesyal na titik, o link, kasi simpleng teksto lamang ang buod."
},
"nags.summary-special-formatting.title": {
"defaultMessage": "Linisin ang buod"
},
"nags.summary-too-short.description": {
"defaultMessage": "Ang iyong buod ay may {length, plural, one {# titik} other {# na titik}}. Hindi kukulang sa {minChars, plural, one {# titik} other {# na titik}} ang inimumungkahi sa paglikha ng makabuluhan at nakapaaanyayang paglalarawan."
},
"nags.summary-too-short.title": {
"defaultMessage": "Palawakin ang buod"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "Ang pagpapanatili na malinis ang Pangalan ng proyekto ay tininityak na madali itong mahanap at maaalala. Ang impormasyon ukol sa bersiyon at loader ay sarili na naming ipapakita para sa iyong proyekto."
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "Linisin ang pangalan"
},
"nags.too-many-tags-server.description": {
"defaultMessage": "Nakapili ka ng {tagCount, plural, one {# tag} other {# na tag}}. Isaalang-alang na mabawasan ito sa {maxTagCount} o mas kaunti pa upang higit na tutumpak sa resulta ng paghanap ang iyong server."
},
@@ -246,4 +225,3 @@
"defaultMessage": "Bisitahin ang mga setting sa mga link"
}
}
@@ -113,12 +113,6 @@
"nags.invalid-license-url.title": {
"defaultMessage": "Ajouter un lien de licence valide"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "Lutilisation de raccourcisseurs de liens ou de toute autre méthode visant à masquer la destination dun lien, que ce soit pour vos liens externes ou votre lien de licence, est interdite. Veuillez nutiliser que des liens complets appropriés."
},
"nags.link-shortener-usage.title": {
"defaultMessage": "Ne pas utiliser de raccourcisseurs de liens"
},
"nags.long-headers.description": {
"defaultMessage": "{count, plural, one {# en-tête} other {# en-têtes}} dans votre description {count, plural, one {est trop long} other {sont trop longs}}. Les en-têtes doivent être concis et servir de titres de section, et non de phrases complètes."
},
@@ -221,30 +215,15 @@
"nags.settings.versions.title": {
"defaultMessage": "Accéder aux paramètres des versions"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "Votre résumé ne peut pas être identique au nom de votre projet. Il est important de créer un résumé informatif et attrayant."
},
"nags.summary-same-as-title.title": {
"defaultMessage": "Rendez le résumé unique"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "Votre résumé ne doit pas contenir de mise en forme, de retours à la ligne, de caractères spéciaux ni de liens, car il sera affiché en texte brut uniquement."
},
"nags.summary-special-formatting.title": {
"defaultMessage": "Nettoyer le résumé"
},
"nags.summary-too-short.description": {
"defaultMessage": "Votre résumé contient {length, plural, one {# caractère} other {# caractères}}. Il est recommandé den avoir au moins {minChars, plural, one {# caractère} other {# caractères}} pour obtenir un résumé informatif et attrayant."
},
"nags.summary-too-short.title": {
"defaultMessage": "Développer le résumé"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "Garder un nom de projet clair le rend plus mémorisable et plus facile à trouver. Les informations de version et de loader sont automatiquement affichées à côté de votre projet."
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "Nettoyer le nom"
},
"nags.too-many-languages.description": {
"defaultMessage": "Vous avez sélectionné {languageCount, plural, one {# langue} other {# langues}}. Veuillez indiquer uniquement les langues prises en charge par votre serveur."
},
@@ -291,4 +270,3 @@
"defaultMessage": "Accéder aux paramètres des liens"
}
}
@@ -107,12 +107,6 @@
"nags.invalid-license-url.title": {
"defaultMessage": "הוסף קישור רישיון חוקי"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "שימוש במקצרי קישורים או בשיטות אחרות כדי לטשטש את היעד של קישור אסור בקישורים חיצוניים או בקישור הרישיון. אנא השתמש רק בקישורים מלאים ומתאימים."
},
"nags.link-shortener-usage.title": {
"defaultMessage": "אל תשתמש במקצרי קישורים"
},
"nags.long-headers.title": {
"defaultMessage": "קצר כותרות"
},
@@ -191,12 +185,6 @@
"nags.settings.title": {
"defaultMessage": "בקר בהגדרות כלליות"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "התקציר שלך לא יכול להיות זהה לשם הפרוייקט שלך. זה חשוב ליצור תקציר אינפורמטיבי ומעניין."
},
"nags.summary-same-as-title.title": {
"defaultMessage": "עשה את התקציר שלך ייחודי"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "התקציר שלך לא יכול לכלול פרמוט, הפסקות שורה, תווים מיוחדים או קישורים, מכיוון שהוא יוצג בטקסט פשוט."
},
@@ -206,12 +194,6 @@
"nags.summary-too-short.title": {
"defaultMessage": "הארך את התקציר"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "שמירה על שם הפרויקט נקי הופכת אותו לזכיר וקל יותר למציאה. פרטי הגרסה והטוען מוצגים אוטומטית לצד הפרויקט."
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "נקה את השם"
},
"nags.too-many-tags-server.title": {
"defaultMessage": "בחר תגים מדוייקים"
},
@@ -113,12 +113,6 @@
"nags.invalid-license-url.title": {
"defaultMessage": "Adj meg egy érvényes licenszlinket"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "Tilos linkrövidítők vagy más módszerek használata a külső linkek vagy licenszkapcsolatok céljának elrejtésére. Kérlek csak megfelelő, teljes hosszúságú linkeket használj."
},
"nags.link-shortener-usage.title": {
"defaultMessage": "Ne használj linkrövidítőket"
},
"nags.long-headers.description": {
"defaultMessage": "A leírásodban {count} fejléc túl hosszú. A fejlécek legyenek tömörek, és csak szakaszcímként szolgáljanak, ne teljes mondatként."
},
@@ -221,30 +215,15 @@
"nags.settings.versions.title": {
"defaultMessage": "Verzió-beállítások meglátogatása"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "Az összegzés nem lehet azonos a projekt nevével. Fontos, hogy informatív és figyelemfelkeltő összegzést készíts."
},
"nags.summary-same-as-title.title": {
"defaultMessage": "Tedd az összegzést egyedivé"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "Az összegzés nem tartalmazhat formázást, sortöréseket, speciális karaktereket vagy linkeket, mivel az összefoglalás csak sima szöveget jelenít meg."
},
"nags.summary-special-formatting.title": {
"defaultMessage": "Tisztítsd meg az összegzést"
},
"nags.summary-too-short.description": {
"defaultMessage": "A jelenlegi összegzés {length} karakter hosszú. Egy informatív és figyelemfelkeltő összegzéshez legalább {minChars} karakter ajánlott."
},
"nags.summary-too-short.title": {
"defaultMessage": "Fejtsd ki az összegzést"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "Ha a projekt neve egyszerű, az könnyebben megjegyezhető és könnyebben megtalálható lesz. A verzió- és a betöltőinformációk automatikusan megjelennek a projekt mellett."
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "Tisztítsd meg a nevet"
},
"nags.too-many-languages.description": {
"defaultMessage": "{languageCount} nyelvet választottál ki. Kérlek csak azokat a nyelveket válaszd ki, amelyeket a szerver aktívan támogat."
},
@@ -291,4 +270,3 @@
"defaultMessage": "Látogasd meg a linkbeállításokat"
}
}
@@ -107,12 +107,6 @@
"nags.invalid-license-url.title": {
"defaultMessage": "Tambah tautan lisensi yang sah"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "Penggunaan pemendek tautan atau metode lain untuk mengaburkan tujuan tautan pada tautan eksternal atau tautan lisensi Anda tidak diperbolehkan. Mohon hanya menggunakan tautan penuh yang sesuai saja."
},
"nags.link-shortener-usage.title": {
"defaultMessage": "Jangan gunakan pemendek tautan"
},
"nags.long-headers.description": {
"defaultMessage": "{count, plural, other {# tajuk}} dalam keterangan Anda{count, plural, other{}} terlalu panjang. Tajuk seharusnya ringkas dan bertindak sebagai judul suatu bagian, bukan kalimat penuh."
},
@@ -209,30 +203,15 @@
"nags.settings.title": {
"defaultMessage": "Kunjungi pengaturan umum"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "Ringkasan Anda tidak boleh sama dengan nama proyek Anda. Membuat ringkasan yang informatif dan menarik adalah penting."
},
"nags.summary-same-as-title.title": {
"defaultMessage": "Buat ringkasan yang unik"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "Ringkasan Anda seharusnya tidak mengandung pemformatan, pemecah baris, karakter khusus, atau tautan, karena ringkasan hanya akan menampilkan teks biasa."
},
"nags.summary-special-formatting.title": {
"defaultMessage": "Bersihkan ringkasan"
},
"nags.summary-too-short.description": {
"defaultMessage": "Panjang ringkasan Anda adalah {length, plural, other {# karakter yang dapat dibaca}}. Setidaknya {minChars, plural, other {# karakter}} disarankan untuk membuat keterangan yang informatif dan menarik."
},
"nags.summary-too-short.title": {
"defaultMessage": "Kembangkan ringkasan"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "Nama proyek yang ringkas membuatnya mudah diingat dan memudahkan pencarian. Informasi versi dan pemuat akan ditampilkan bersamaan secara otomatis dengan proyek Anda."
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "Bersihkan nama"
},
"nags.too-many-languages.description": {
"defaultMessage": "Anda telah memilih {languageCount, plural, other {# bahasa}}. Mohon hanya memilih bahasa yang benar-benar didukung oleh server Anda."
},
@@ -273,4 +252,3 @@
"defaultMessage": "Kunjungi pengaturan tautan"
}
}
@@ -107,12 +107,6 @@
"nags.invalid-license-url.title": {
"defaultMessage": "Aggiungi un link alla licenza valido"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "L'utilizzo di link brevi o altri metodi per oscurare la destinazione di un link, sia nei collegamenti esterni sia nel link alla licenza, è vietato. Usa solo link completi e trasparenti."
},
"nags.link-shortener-usage.title": {
"defaultMessage": "Non abbreviare i link"
},
"nags.long-headers.description": {
"defaultMessage": "{count, plural, one {Una intestazione} other {# intestazioni}} nella descrizione {count, plural, one {è troppo lunga} other {sono troppo lunghe}}. Tipicamente dovrebbero essere brevi e funzionare da titoli, non da frasi complete."
},
@@ -212,30 +206,15 @@
"nags.settings.versions.title": {
"defaultMessage": "Impostazioni delle versioni"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "La sintesi non può essere identica al nome del progetto. Ѐ importante scriverne una informativa e accattivante."
},
"nags.summary-same-as-title.title": {
"defaultMessage": "Scrivi una sintesi unica"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "La sintesi non dovrebbe contenere formattazioni, interruzioni di riga, caratteri speciali o link, perché verrà mostrata come testo semplice."
},
"nags.summary-special-formatting.title": {
"defaultMessage": "Rivedi la sintesi"
},
"nags.summary-too-short.description": {
"defaultMessage": "La sintesi è di {length, plural, one {# carattere} other {# caratteri}}. Usane almeno {minChars} per tenerla allettante e informativa."
},
"nags.summary-too-short.title": {
"defaultMessage": "Espandi la sintesi"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "Mantenere breve il nome del progetto lo rende facile da ricordare e da trovare. Le informazioni sulla versione e sul loader vengono mostrate automaticamente insieme al progetto."
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "Rivedi il nome"
},
"nags.too-many-languages.description": {
"defaultMessage": "Hai selezionato {languageCount, plural, one {# lingua} other {# lingue}}. Seleziona solo quelle che il server usa attivamente."
},
@@ -110,12 +110,6 @@
"nags.invalid-license-url.title": {
"defaultMessage": "有効なライセンスのリンクを追加する"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "外部リンクやライセンスのリンクにおいて、リンク先を不明確にするためのリンク短縮サービスやその他の方法の使用は禁止されています。適切なフルリンクのみを使用してください。"
},
"nags.link-shortener-usage.title": {
"defaultMessage": "短縮リンクサービスを使用しないでください"
},
"nags.long-headers.description": {
"defaultMessage": "説明文にある{count, plural, other {#個のヘッダー}}{count, plural, other{}}はあまりに長すぎます。ヘッダーは簡潔で、セクションの見出しとして機能すべきであり、完全な文として使用すべきではありません。"
},
@@ -215,30 +209,15 @@
"nags.settings.title": {
"defaultMessage": "一般設定を表示"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "プロジェクトの名前と要約は同じにできません。情報豊富で魅力的な要約を作成することが重要です。"
},
"nags.summary-same-as-title.title": {
"defaultMessage": "要約を独自のものにしましょう"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "要約には、プレーンテキストのみが表示されるため、フォーマット、改行、特殊文字、またはリンクを含めないでください。"
},
"nags.summary-special-formatting.title": {
"defaultMessage": "要約を整理"
},
"nags.summary-too-short.description": {
"defaultMessage": "要約文が{length, plural, other {#文字}}しかありません。より明確で分かりやすい要約文を作成するためには、少なくとも{minChars, plural, other {#文字}}以上であることが推奨されます。"
},
"nags.summary-too-short.title": {
"defaultMessage": "要約を展開"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "プロジェクトの名前を簡潔にすることで、検索しやすくなります。バージョンとローダー情報は、プロジェクトと共に自動的に表示されます。"
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "名前を整理"
},
"nags.too-many-languages.description": {
"defaultMessage": "{languageCount, plural, one {# language} other {# languages}}を選択しました。サーバーが実際にサポートしている言語のみを一覧表示してください。"
},
@@ -285,4 +264,3 @@
"defaultMessage": "リンク設定を表示"
}
}
@@ -107,12 +107,6 @@
"nags.invalid-license-url.title": {
"defaultMessage": "올바른 라이선스 링크를 추가해 주세요"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "외부 링크나 라이선스 링크에서 링크 단축기 및 기타 방법의 사용은 금지되어 있습니다. 반드시 전체 길이의 링크만 사용해 주세요."
},
"nags.link-shortener-usage.title": {
"defaultMessage": "링크 단축기 사용 금지"
},
"nags.long-headers.description": {
"defaultMessage": "설명의 {count, plural, other {#개의 헤더}}{count, plural, other {}}가 너무 깁니다. 헤더는 간결해야 하며, 완전한 문장이 아닌 단락 제목 역할을 해야 합니다."
},
@@ -212,30 +206,15 @@
"nags.settings.versions.title": {
"defaultMessage": "버전 설정 방문"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "요약은 프로젝트 이름과 동일할 수 없습니다. 정보가 충분하고 흥미를 끄는 요약을 작성하는 것이 중요합니다."
},
"nags.summary-same-as-title.title": {
"defaultMessage": "고유한 요약 만들기"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "요약은 일반 텍스트로만 표시되므로 서식, 줄 바꿈, 특수 문자, 링크를 포함해서는 안 됩니다."
},
"nags.summary-special-formatting.title": {
"defaultMessage": "요약 정리"
},
"nags.summary-too-short.description": {
"defaultMessage": "작성한 요약이 {length, plural, other {#자}}입니다. 적절한 정보를 제공하는 요약을 작성하려면 최소 {minChars, plural, other {#자}} 이상을 권장합니다."
},
"nags.summary-too-short.title": {
"defaultMessage": "요약 확장"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "프로젝트 이름을 깔끔하게 유지하면 기억하기 쉽고 찾기도 쉬워집니다. 버전 및 로더 정보는 프로젝트와 함께 자동으로 표시됩니다."
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "이름 정리"
},
"nags.too-many-languages.description": {
"defaultMessage": "언어 {languageCount, plural, other {#}}개를 선택했습니다. 서버가 실제로 지원하는 언어만 선택해 주세요."
},
@@ -276,4 +255,3 @@
"defaultMessage": "링크 설정 방문"
}
}
@@ -95,12 +95,6 @@
"nags.invalid-license-url.title": {
"defaultMessage": "Tambahkan pautan lesen yang sah"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "Penggunaan pemendek pautan atau kaedah lain untuk mengaburkan hala tuju pautan di dalam pautan luaran atau lesen anda adalah dilarang, sila gunakan pautan panjang penuh yang sesuai sahaja."
},
"nags.link-shortener-usage.title": {
"defaultMessage": "Jangan gunakan pemendek pautan"
},
"nags.long-headers.description": {
"defaultMessage": "{count, plural, other {# tajuk}} di dalam keterangan anda {count, plural, other {adalah}} terlalu panjang. Tajuk hendaklah ringkas dan bertindak sebagai tajuk bahagian, bukan ayat penuh."
},
@@ -188,30 +182,15 @@
"nags.settings.title": {
"defaultMessage": "Kunjungi tetapan umum"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "Ringkasan anda tidak boleh sama dengan Nama projek anda. Adalah penting untuk mencipta Ringkasan yang bermaklumat dan menarik."
},
"nags.summary-same-as-title.title": {
"defaultMessage": "Jadikan ringkasan itu unik"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "Ringkasan anda tidak boleh mengandungi pemformatan, pemisah baris, aksara khas atau pautan, kerana ringkasan hanya akan memaparkan teks biasa."
},
"nags.summary-special-formatting.title": {
"defaultMessage": "Bersihkan ringkasan"
},
"nags.summary-too-short.description": {
"defaultMessage": "Panjang ringkasan anda ialah {length, plural, other {# aksara}}. Sekurang-kurangnya {minChars, plural, other {# aksara}} disyorkan untuk membuat ringkasan yang bermaklumat dan menarik."
},
"nags.summary-too-short.title": {
"defaultMessage": "Kembangkan ringkasan"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "Nama projek anda yang kemas akan menjadikannya lebih diingati dan mudah untuk dicari. Maklumat versi dan pemuat dipaparkan secara automatik bersama projek anda."
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "Bersihkan nama"
},
"nags.too-many-tags-server.description": {
"defaultMessage": "Anda telah memilih sebanyak {tagCount, plural, other {# tag}} Pertimbangkan untuk mengurangkannya kepada kurang atau sama dengan {maxTagCount} untuk memastikan pelayan anda dipaparkan dalam hasil carian yang berkaitan."
},
@@ -246,4 +225,3 @@
"defaultMessage": "Kunjungi tetapan pautan"
}
}
@@ -107,12 +107,6 @@
"nags.invalid-license-url.title": {
"defaultMessage": "Voeg een geldige licentie link toe"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "Gebruik van linkverkorters of andere methodes om te verbergen waar een link naar toe gaat in je externe links of licentie link is niet toegestaan, gelieve alleen volledige lengte links te gebruiken."
},
"nags.link-shortener-usage.title": {
"defaultMessage": "Gebruik geen link verkorters"
},
"nags.long-headers.description": {
"defaultMessage": "{count, plural, one {# kop} other {# koppen}} in jouw beschrijving {count, plural, one {is} other {zijn}} te lang. Koppen moeten beknopt zijn en dienen als onderdeeltitels, niet als volledige zinnen."
},
@@ -212,30 +206,15 @@
"nags.settings.versions.title": {
"defaultMessage": "Versie-instellingen bekijken"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "Je samenvatting kan niet dezelfde naam hebben als je projectnaam. Het is belangrijk om een informatieve en interessante samenvatting te maken."
},
"nags.summary-same-as-title.title": {
"defaultMessage": "Maak je samenvatting uniek"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "Je samenvatting moet geen opmaak, regeleindes, speciale karakters of links, omdat de samenvatting alleen text, uitlicht."
},
"nags.summary-special-formatting.title": {
"defaultMessage": "Verhelder de samenvatting"
},
"nags.summary-too-short.description": {
"defaultMessage": "Je samenvatting is {length, plural, one {# character} other {# characters}}. Minimaal {minChars, plural, one {# character} other {# characters}} is aanbevolen om een informatieve en interessante samevatting te maken."
},
"nags.summary-too-short.title": {
"defaultMessage": "Breidt de samenvatting uit"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "De naam van je project duidelijk houden zorgt ervoor dat het makkelijk te vinden is. Versie en lader informatie is automatisch bij je project uitgelicht."
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "Maak de naam duidelijk"
},
"nags.too-many-languages.description": {
"defaultMessage": "Je hebt {languageCount, plural, one {# taal} other {# talen}} geselecteerd. Geef alleen de talen op die je server daadwerkelijk ondersteunt."
},
@@ -282,4 +261,3 @@
"defaultMessage": "Bezoek linkjes instellingen"
}
}
@@ -101,12 +101,6 @@
"nags.invalid-license-url.title": {
"defaultMessage": "Legg til en gyldig lisenslenke"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "Det er ikke tillatt å bruke lenkeforkortere eller andre metoder for å skjule hvor en lenke leder, verken i eksterne lenker eller lisenslenker. Bruk alltid fullstendige lenker."
},
"nags.link-shortener-usage.title": {
"defaultMessage": "Ikke bruk lenkeforkortere"
},
"nags.long-headers.description": {
"defaultMessage": "{count, plural, one {# header} other {# headers}} i beskrivelsen din {count, plural, one {er} other {er}} for lange. Overskrifter skal være korte og fungere som seksjonstitler, ikke som hele setninger."
},
@@ -203,30 +197,15 @@
"nags.settings.title": {
"defaultMessage": "Gå til generelle innstillinger"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "Sammendraget kan ikke være det samme som prosjektnavnet. Det er viktig å lage et informativt og engasjerende sammendrag."
},
"nags.summary-same-as-title.title": {
"defaultMessage": "Gjør sammendraget unikt"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "Sammendraget skal ikke inneholde formatering, linjeskift, spesialtegn eller lenker, siden det bare vises som ren tekst."
},
"nags.summary-special-formatting.title": {
"defaultMessage": "Rydd opp i sammendraget"
},
"nags.summary-too-short.description": {
"defaultMessage": "Din oppsummering er {length, plural, one {# character} other {# characters}}. Minst {minChars, plural, one {# character} other {# characters}} det anbefales å lage et informativt og fristende sammendrag."
},
"nags.summary-too-short.title": {
"defaultMessage": "Utvid sammendraget"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "Et ryddig prosjektnavn gjør det lettere å huske og finne. Versjons- og loader-informasjon vises automatisk sammen med prosjekt."
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "Rydd opp i navnet"
},
"nags.too-many-tags-server.description": {
"defaultMessage": "Du har valgt {tagCount, plural, one {# tagg} other {# tagger}}. Vennligst reduser antallet til {maxTagCount} eller færre for å sikre at serveren din vises i relevante søkeresultater."
},
@@ -258,4 +237,3 @@
"defaultMessage": "Gå til lisensinnstillinger"
}
}
@@ -107,12 +107,6 @@
"nags.invalid-license-url.title": {
"defaultMessage": "Dodaj poprawny link do licencji"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "Używanie skracaczy linków lub innych metod ukrywania gdzie prowadzi link w twoich linkach zewnętrznych oraz linku licencji jest niedozwolone, używaj tylko linków o pełnej długości."
},
"nags.link-shortener-usage.title": {
"defaultMessage": "Nie używaj skracaczy linków"
},
"nags.long-headers.description": {
"defaultMessage": "{count, plural, one {# nagłówek} few {# nagłówki} other {# nagłówków}} w twoim opisie {count, plural, one {jest za długi} few {są za długie} other {jest za długich}}. Nagłówki powinny być zwięzłe i pełnić funkcję tytułów sekcji, a nie pełnych zdań."
},
@@ -212,30 +206,15 @@
"nags.settings.versions.title": {
"defaultMessage": "Zobacz ustawienia wersji"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "Twoje podsumowanie nie może być kopią nazwy projektu. Ważne jest, by stworzyć bogate w informacje i interesujące podsumowanie."
},
"nags.summary-same-as-title.title": {
"defaultMessage": "Podsumowanie powinno być unikatowe"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "Twoje podsumowanie nie powinno zawierać formatowania, podziałów wiersza, specjalnych znaków lub linków, ponieważ podsumowanie wyświetla tylko prosty tekst."
},
"nags.summary-special-formatting.title": {
"defaultMessage": "Uporządkuj podsumowanie"
},
"nags.summary-too-short.description": {
"defaultMessage": "Twoje podsumowanie składa się z {length, plural, one {# znaku} other {# znaków}}. Zalecane jest użycie chociaż {minChars, plural, one {# znaku} other {# znaków}} by stworzyć informatywne i ciekawe podsumowanie."
},
"nags.summary-too-short.title": {
"defaultMessage": "Rozwiń podsumowanie"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "Utrzymanie nazwy projektu w czystości sprawia, że łatwiej ją zapamiętać i znaleźć. Informacje o wersji i loaderze są automatycznie wyświetlane obok projektu."
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "Oczyść nazwę"
},
"nags.too-many-languages.description": {
"defaultMessage": "Wybrano {languageCount, plural, one {# język} few {# języki} other {# języków}}. Podaj tylko języki, które są faktycznie wspierane przez Twój serwer."
},
@@ -276,4 +255,3 @@
"defaultMessage": "Odwiedź ustawienia linków"
}
}
@@ -113,12 +113,6 @@
"nags.invalid-license-url.title": {
"defaultMessage": "Adicione um link de licença válido"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "É proibido o uso de encurtadores de links ou outros métodos para ocultar o destino de um link nos seus links externos ou nos links de licença. Use somente links completos e apropriados."
},
"nags.link-shortener-usage.title": {
"defaultMessage": "Não use encurtadores de link"
},
"nags.long-headers.description": {
"defaultMessage": "{count, plural, one {# cabeçalho} other {# cabeçalhos}} na sua descrição {count, plural, one {é muito longo} other {são muito longos}}. Os cabeçalhos devem ser concisos e funcionar como títulos de seção, não como frases completas."
},
@@ -221,30 +215,15 @@
"nags.settings.versions.title": {
"defaultMessage": "Visitar configurações das versões"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "Seu resumo não pode ser igual ao nome do seu projeto. É importante criar um resumo informativo e atraente."
},
"nags.summary-same-as-title.title": {
"defaultMessage": "Torne o resumo único"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "Seu resumo não deve incluir formatação, quebras de linha, caracteres especiais ou links, pois o resumo será exibido como texto simples."
},
"nags.summary-special-formatting.title": {
"defaultMessage": "Limpe o resumo"
},
"nags.summary-too-short.description": {
"defaultMessage": "Seu sumário é {length, plural, one {# caractére} other {# caractéres}}. É recomendado pelo menos {minChars, plural, one {# caractére} other {# caractéres}} para criar um sumário informativo e atraente."
},
"nags.summary-too-short.title": {
"defaultMessage": "Aumente o resumo"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "Manter o Nome do seu projeto limpo faz ele ser memorável e mais fácil de achar. As informações sobre a versão e o carregador são automaticamente exibidas com seu projeto."
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "Limpe o nome"
},
"nags.too-many-languages.description": {
"defaultMessage": "Você selecionou {languageCount, plural, one {# idioma} other {# idiomas}}. Por favor, liste apenas os idiomas que seu servidor usa ativamente."
},
@@ -291,4 +270,3 @@
"defaultMessage": "Visitar página de links"
}
}
@@ -101,12 +101,6 @@
"nags.invalid-license-url.title": {
"defaultMessage": "Adiciona um link de licença válido"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "É proibido o uso de encurtadores de links ou outros métodos que ocultem o destino de um link nos teus links externos ou link de licença, por favor utiliza apenas links completos."
},
"nags.link-shortener-usage.title": {
"defaultMessage": "Não uses encurtadores de links"
},
"nags.long-headers.description": {
"defaultMessage": "{count, plural, one {# cabeçalho} other {# cabeçalhos}} na tua descrição {count, plural, one {é demasiado longo} other {são demasiado longos}}. Os cabeçalhos devem ser concisos e funcionar como títulos de secção, não como frases completas."
},
@@ -200,30 +194,15 @@
"nags.settings.title": {
"defaultMessage": "Vê as definições gerais"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "O teu sumário não pode ser igual ao Nome do teu projeto. É importante criares um Sumário informativo e apelativo."
},
"nags.summary-same-as-title.title": {
"defaultMessage": "Cria um sumário único"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "O teu sumário não deve conter formatação, quebras de linha, caracteres especiais, ou links, visto que o sumário aparecerá apenas como texto simples."
},
"nags.summary-special-formatting.title": {
"defaultMessage": "Limpa o sumário"
},
"nags.summary-too-short.description": {
"defaultMessage": "O teu resumo tem {length, plural, one {# caractere} other {# caracteress}}. Pelo menos {minChars, plural, one {# caractere} other {# caracteres}} são recomendados para criar um resumo informativo e apelativo."
},
"nags.summary-too-short.title": {
"defaultMessage": "Expande o sumário"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "Manter o Nome do teu projeto limpo e simples fá-lo memorável e fácil de encontrar. Informação de versão e carregador é mostrada automaticamente com o teu projeto."
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "Limpa o nome"
},
"nags.too-many-tags-server.description": {
"defaultMessage": "Selecionaste {tagCount, plural, one {# tag} other {# tags}}. Reduz para {maxTagCount} ou menos para garantir que o teu servidor aparece nos resultados de pesquisa relevantes."
},
@@ -258,4 +237,3 @@
"defaultMessage": "Vê as definições de links"
}
}
@@ -89,12 +89,6 @@
"nags.invalid-license-url.title": {
"defaultMessage": "Adaugă un link valid către licență"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "Utilizarea de prescurtări de link-uri sau alte metode de a ascunde destinația unui link în secțiunea de link-uri externe sau în link-ul licenței este interzisă, te rugăm să folosești doar link-uri complete și valide."
},
"nags.link-shortener-usage.title": {
"defaultMessage": "Nu utilizați prescurtări de link"
},
"nags.long-headers.description": {
"defaultMessage": "{count, plural, one {# antet} other {# antete}} în descrierea ta {count, plural, one {este} other {sunt}} prea lung. Antetul trebuie să fie concis și să acționeze ca titluri de secțiune, nu ca propoziții întregi."
},
@@ -179,30 +173,15 @@
"nags.settings.title": {
"defaultMessage": "Vizitează setările generale"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "Rezumatul tău nu poate fi identic cu numele proiectului. Este important să creezi un rezumat informativ și atractiv."
},
"nags.summary-same-as-title.title": {
"defaultMessage": "Fă rezumatul unic"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "Rezumatul tău nu trebuie să conțină formatare, salturi de linie, caractere speciale sau link-uri, deoarece va fi afișat doar ca text simplu."
},
"nags.summary-special-formatting.title": {
"defaultMessage": "Curăță rezumatul"
},
"nags.summary-too-short.description": {
"defaultMessage": "Sumarul dumneavoastră are o lungime de {length, plural, one {# caracter} other {#caractere}}. Minim {minChars, plural, one {# caracter este recomandat} other {#caractere sunt recomandate}} pentru a creea un sumar informativ ți incitant."
},
"nags.summary-too-short.title": {
"defaultMessage": "Extinde rezumatul"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "Prin a face numele proiectului tău curat îl faci și mai ușor de ținut minte si de găsit. Informații de versiune și loader sunt afișate automatic lângă proiectul dumneavoastră."
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "Curăță numele"
},
"nags.too-many-tags-server.description": {
"defaultMessage": "Ați selectat {tagCount, plural,one {#etichetă} other {etichetă}}. Vă rugăm să reduceți la{maxTagCount} sau mai puține pentru a vă asigura că serverul dvs. apare în rezultatele de căutare relevante."
},
@@ -234,4 +213,3 @@
"defaultMessage": "Vizitează setările link-urilor"
}
}
@@ -113,12 +113,6 @@
"nags.invalid-license-url.title": {
"defaultMessage": "Исправьте URL лицензии"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "Использование сервисов сокращения ссылок и других способов скрытия адреса лицензии и внешних ссылок запрещено. Указывайте полные ссылки."
},
"nags.link-shortener-usage.title": {
"defaultMessage": "Не сокращайте ссылки"
},
"nags.long-headers.description": {
"defaultMessage": "{count, plural, one {# заголовок} few {# заголовка} other {# заголовков}} в описании слишком {count, plural, one {длинный} other {длинные}}. Заголовки должны быть краткими названиями разделов, а не предложениями."
},
@@ -221,30 +215,15 @@
"nags.settings.versions.title": {
"defaultMessage": "Настроить версии"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "Сводка не может совпадать с названием проекта. Сделайте её понятной и интересной."
},
"nags.summary-same-as-title.title": {
"defaultMessage": "Разнообразьте сводку"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "В сводке отображается простой текст. Она не должна содержать форматирование, переносы строк, ссылки и спецсимволы."
},
"nags.summary-special-formatting.title": {
"defaultMessage": "Упростите сводку"
},
"nags.summary-too-short.description": {
"defaultMessage": "В сводке {length, plural, one {# символ} few {# символа} other {# символов}}. Для понятной и интересной сводки рекомендуется не менее {minChars, plural, one {# символа} few {# символов} other {# символов}}."
},
"nags.summary-too-short.title": {
"defaultMessage": "Расширьте сводку"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "Проект с простым названием проще запомнить и найти. Информация о версии и загрузчике указывается автоматически."
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "Упростите название"
},
"nags.too-many-languages.description": {
"defaultMessage": "{languageCount, plural, one {Выбран # язык} few {Выбрано # языка} other {Выбрано # языков}}. Оставьте только те, которые сервер активно поддерживает."
},
@@ -291,4 +270,3 @@
"defaultMessage": "Настроить ссылки"
}
}
@@ -113,12 +113,6 @@
"nags.invalid-license-url.title": {
"defaultMessage": "Dodaj važeći link licence"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "Upotreba skraćivača linkova ili drugih metoda za prikrivanje mesta gde link može voditi u tvojim spoljnim linkovima ili licencnim linkovima je zabranjena, koristi samo odgovarajuće linkove pune dužine."
},
"nags.link-shortener-usage.title": {
"defaultMessage": "Ne koristi skraćivače linkova"
},
"nags.long-headers.description": {
"defaultMessage": "{count, plural, one {# zaglavlje} other {# zaglavlja}} u tvojem opisu {count, plural, one {# je predugo} other {# su preduga}}. Zaglavlja treba da budu sažeti i da služe kao naslovi odeljaka, a ne kao cele rečenice."
},
@@ -221,30 +215,15 @@
"nags.settings.versions.title": {
"defaultMessage": "Posetite podešavanja verzija"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "Tvog rezime ne može biti isti kao naziv tvog projekta. Važno je da napraviš informativan i privlačan rezime."
},
"nags.summary-same-as-title.title": {
"defaultMessage": "Napravi rezime jedinstvenim"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "Tvoj rezime ne bi trebalo da sadrži formatiranje, prelome redova, specijalne znakove ili linkove, jer će rezime prikazivati samo običan tekst."
},
"nags.summary-special-formatting.title": {
"defaultMessage": "Razjasni rezime"
},
"nags.summary-too-short.description": {
"defaultMessage": "Tvoj rezime je {length, plural, one {# slovo} other {# slova}}. Najmanje {minChars, plural, one {# slovo} other {# slova}} je preporučeno da bi se kreirao informativan i privlačan rezime."
},
"nags.summary-too-short.title": {
"defaultMessage": "Proširi rezime"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "Održavanje čistog imena vašeg projekta ga čini lakšim za pamćenje i pronalaženje. Informacije o verziji i programu za učitavanje se automatski prikazuju pored vašeg projekta."
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "Počisti Ime"
},
"nags.too-many-languages.description": {
"defaultMessage": "Izabrao si {languageCount, plural, one {# jezik} other {# jezika}}. Navedi samo jezike koje tvoj server aktivno podržava."
},
@@ -291,4 +270,3 @@
"defaultMessage": "Posjeti podešavanja linkova"
}
}
@@ -113,12 +113,6 @@
"nags.invalid-license-url.title": {
"defaultMessage": "Lägg till en giltig licenslänk"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "Att använda en länkförkortare eller andra metoder för att gömma vart en länk leder i dina externa länkar eller licenslänkar är förbjudet, vänligen använd lämpliga länkar i full längd."
},
"nags.link-shortener-usage.title": {
"defaultMessage": "Använd inte länkförkortare"
},
"nags.long-headers.description": {
"defaultMessage": "{count, plural, one {# rubrik} other {# rubriker}} i din beskrivning är för {count, plural, one {lång} other {långa}}. Rubriker ska vara korta och fungera som titlar till olika delar, inte hela meningar."
},
@@ -221,30 +215,15 @@
"nags.settings.versions.title": {
"defaultMessage": "Besök versionsinställningar"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "Din sammanfattning kan inte vara samma som projektnamnet. Det är viktigt att skriva en informativ och lockande sammanfattning."
},
"nags.summary-same-as-title.title": {
"defaultMessage": "Gör sammanfattningen unik"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "Din sammanfattning bör inte innehålla formatering, radbrytningar, specialtecken eller länkar. Den visas endast som oformaterad text."
},
"nags.summary-special-formatting.title": {
"defaultMessage": "Rensa upp sammanfattningen"
},
"nags.summary-too-short.description": {
"defaultMessage": "Din sammanfattning är {length} tecken långt. Åtminstone {minChars} tecken rekommenderas för en informativ och lockande sammanfattning"
},
"nags.summary-too-short.title": {
"defaultMessage": "Expandera sammanfattningen"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "Om du håller projektetnamnet fint blir det minnesvärt och lättare att hitta. Versions- och laddarinformation visas automatiskt tillsammans med ditt projekt."
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "Rensa upp namnet"
},
"nags.too-many-languages.description": {
"defaultMessage": "Du har valt {languageCount} språk. Vänligen skriv endast upp språk som din server aktivt stödjer."
},
@@ -291,4 +270,3 @@
"defaultMessage": "Besök länkinställningar"
}
}
@@ -38,9 +38,6 @@
"nags.invalid-license-url.title": {
"defaultMessage": "เพิ่มลิงก์ไลเซนส์ที่ถูกต้อง"
},
"nags.link-shortener-usage.title": {
"defaultMessage": "ห้ามใช้ตัวย่อลิงก์"
},
"nags.missing-alt-text.title": {
"defaultMessage": "เพิ่มคำอธิบายรูปภาพ"
},
@@ -51,4 +48,3 @@
"defaultMessage": "เลือกแท็ก"
}
}
@@ -113,12 +113,6 @@
"nags.invalid-license-url.title": {
"defaultMessage": "Geçerli bir lisans bağlantısı ekle"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "Link kısaltma gibi yönlendirileceğin link gizleme yöntemlerine izin verilmiyor, lütfen sadece uygun tam linkler kullan."
},
"nags.link-shortener-usage.title": {
"defaultMessage": "Bağlantı kısaltıcı kullanma"
},
"nags.long-headers.description": {
"defaultMessage": "Açıklamandaki {count, plural, one {# başlık} other {# başlık}} fazla {count, plural, one {uzun} other {uzun}}. Başlıklar kısa olmalı ve tam cümleler değil, bölüm başlıkları olmalı."
},
@@ -221,30 +215,15 @@
"nags.settings.versions.title": {
"defaultMessage": "Sürüm ayarlarını ziyaret et"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "Özetin proje adın ile aynı olamaz. Bilgilendirici ve ilgi çekici bir özet yazmak çok önemlidir."
},
"nags.summary-same-as-title.title": {
"defaultMessage": "Özetin özgün olsun"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "Özetin düz yazı olarak gösterildiği için formatlama, yeni satırlar, özel karakterler veya bağlantılar içermemelidir."
},
"nags.summary-special-formatting.title": {
"defaultMessage": "Özetini düzenle"
},
"nags.summary-too-short.description": {
"defaultMessage": "Özetin {length} karakter. Bilgilendirici ve çekici bir özet için en az {minChars} karakter önerilir."
},
"nags.summary-too-short.title": {
"defaultMessage": "Özeti uzat"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "Projenizin Adını temiz tutmak onu unutulmaz yapar ve bulunmasını kolaylaştırır. Sürüm ve yükleyici bilgileri projenizin yanında otomatik olarak görüntülenir."
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "İsmi düzenle"
},
"nags.too-many-languages.description": {
"defaultMessage": "{languageCount, plural,one {# dil}other {# dil}} seçtiniz. Lütfen sadece sunucunuzun aktif olarak desteklediği dilleri seçiniz."
},
@@ -288,4 +267,3 @@
"defaultMessage": "Bağlantı ayarlarına göz at"
}
}
@@ -113,12 +113,6 @@
"nags.invalid-license-url.title": {
"defaultMessage": "Додайте правильне посилання на ліцензію"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "Використання вкорочувачів посилань або інших методів для приховування адрес зовнішніх посилань й посилань на ліцензію заборонено. Використовуйте лише повні посилання."
},
"nags.link-shortener-usage.title": {
"defaultMessage": "Не використовуйте вкорочувачі посилань"
},
"nags.long-headers.description": {
"defaultMessage": "{count, plural, one {# заголовок} few {# заголовки} other {# заголовків}} у вашому описі {count, plural, one {є задовгим} other {є задовгими}}. Заголовки мають бути стислими та виступати в ролі назв розділів, а не повних речень."
},
@@ -221,30 +215,15 @@
"nags.settings.versions.title": {
"defaultMessage": "Відвідайте налаштування версій"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "Ваша анотація не може бути такою ж, як назва вашого проєкту. Це важливо для створення інформативної та привабливої анотації."
},
"nags.summary-same-as-title.title": {
"defaultMessage": "Зробіть анотацію унікальною"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "Ваша анотація не має містити форматування, розривів рядків, спеціальних символів або посилань, оскільки анотація показує лише звичайний текст."
},
"nags.summary-special-formatting.title": {
"defaultMessage": "Очистьте анотацію"
},
"nags.summary-too-short.description": {
"defaultMessage": "Ваша анотація складається з {length, plural, one {# символа} other {# символів}}. Рекомендуємо зробити анотацію хоча б з {minChars, plural, one {# символа} other {# символів}}, щоб вона виглядала інформативно й привабливо."
},
"nags.summary-too-short.title": {
"defaultMessage": "Доповніть анотацію"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "Якщо вибрати лаконічну назву для проєкту, його буде легше запам’ятати та знайти. Інформація про версію та завантажувач автоматично показується поруч із назвою."
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "Скоротіть назву"
},
"nags.too-many-languages.description": {
"defaultMessage": "Ви вибрали {languageCount, plural, one {# мову} few {# мови} other {# мов}}. Вибирайте лише ті, які ваш сервер прямо підтримує."
},
@@ -291,4 +270,3 @@
"defaultMessage": "Відвідайте налаштування посилань"
}
}
@@ -107,12 +107,6 @@
"nags.invalid-license-url.title": {
"defaultMessage": "Thêm liên kết giấy phép hợp lệ"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "Việc sử dụng trình rút gọn liên kết hoặc các phương pháp khác để che giấu đích đến của liên kết trong các liên kết bên ngoài hoặc liên kết giấy phép là không được phép. Vui lòng chỉ sử dụng các liên kết đầy đủ và phù hợp."
},
"nags.link-shortener-usage.title": {
"defaultMessage": "Không sử dụng trình rút gọn liên kết"
},
"nags.long-headers.description": {
"defaultMessage": "{count, plural, other {# tiêu đề}} trong phần mô tả của bạn {count, plural, other {quá dài}}. Tiêu đề nên ngắn gọn và đóng vai trò là tiêu đề mục, không phải là câu hoàn chỉnh."
},
@@ -209,30 +203,15 @@
"nags.settings.title": {
"defaultMessage": "Truy cập cài đặt chung"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "Phần tóm tắt của bạn không được trùng với tên dự án. Việc tạo một bản tóm tắt mang tính cung cấp thông tin và thu hút là rất quan trọng."
},
"nags.summary-same-as-title.title": {
"defaultMessage": "Làm cho phần tóm tắt trở nên khác biệt"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "Phần tóm tắt không nên chứa định dạng, xuống dòng, ký tự đặc biệt hoặc liên kết, vì phần tóm tắt chỉ hiển thị văn bản thuần."
},
"nags.summary-special-formatting.title": {
"defaultMessage": "Làm gọn phần tóm tắt"
},
"nags.summary-too-short.description": {
"defaultMessage": "Phần tóm tắt của bạn hiện có {length} ký tự. Khuyến nghị nên có ít nhất {minChars} ký tự để tạo ra một bản tóm tắt đầy đủ thông tin và hấp dẫn."
},
"nags.summary-too-short.title": {
"defaultMessage": "Mở rộng phần tóm tắt"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "Giữ tên dự án gọn gàng sẽ giúp dễ ghi nhớ và dễ tìm kiếm hơn. Thông tin phiên bản và trình tải sẽ được tự động hiển thị cùng với dự án của bạn."
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "Làm gọn tên dự án"
},
"nags.too-many-languages.description": {
"defaultMessage": "Bạn đã chọn {languageCount} ngôn ngữ. Xin hãy chỉ chọn những ngôn ngữ máy chủ của bạn hỗ trợ tích cực."
},
@@ -273,4 +252,3 @@
"defaultMessage": "Truy cập cài đặt liên kết"
}
}
@@ -113,12 +113,6 @@
"nags.invalid-license-url.title": {
"defaultMessage": "添加一个有效的许可证链接"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "禁止使用短链接或其他方式来隐藏外部链接或许可证的实际链接。请务必使用完整的原始链接。"
},
"nags.link-shortener-usage.title": {
"defaultMessage": "不要使用短链接"
},
"nags.long-headers.description": {
"defaultMessage": "描述中有 {count, plural, other {# 个标题}}过长。标题应简洁,用作章节名,不应是完整的句子。"
},
@@ -221,30 +215,15 @@
"nags.settings.versions.title": {
"defaultMessage": "查看版本设置"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "你的简介不能与项目名称相同。撰写一个兼具信息量与吸引力的简介是十分重要的。"
},
"nags.summary-same-as-title.title": {
"defaultMessage": "让你的简介与众不同"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "你的简介不应包含任何格式、换行符、特殊字符或链接,因为简介仅会以纯文本形式展示。"
},
"nags.summary-special-formatting.title": {
"defaultMessage": "清除简介"
},
"nags.summary-too-short.description": {
"defaultMessage": "你的简介包含 {length, plural, other {# 个字符}}。建议至少包含 {minChars, plural, other {# 个字符}},来让简介既丰富又引人入胜。"
},
"nags.summary-too-short.title": {
"defaultMessage": "扩充你的简介"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "保持项目名称简洁,有助于玩家记忆与查找。版本和加载器信息会自动随你的项目一同显示。"
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "清理名称"
},
"nags.too-many-languages.description": {
"defaultMessage": "你选择了 {languageCount, plural, other {# 个语言}},请仅列出你的服务器目前实际支持的语言。"
},
@@ -291,4 +270,3 @@
"defaultMessage": "访问链接设置"
}
}
@@ -113,12 +113,6 @@
"nags.invalid-license-url.title": {
"defaultMessage": "新增有效授權條款連結"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "禁止在你的外部連結或授權條款連結中使用短網址或其他方法來隱藏連結的實際指向,請只使用適當的完整連結。"
},
"nags.link-shortener-usage.title": {
"defaultMessage": "請勿使用短網址"
},
"nags.long-headers.description": {
"defaultMessage": "你的描述中有 {count, plural, other {# 個標題}}{count, plural, other {}}字數過多。標題的作用是作為章節標題,應保持簡潔,避免使用完整的長句子。"
},
@@ -221,30 +215,15 @@
"nags.settings.versions.title": {
"defaultMessage": "前往版本設定"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "你的摘要不能與你的專案名稱相同。撰寫一段既實用又吸引人的摘要非常重要。"
},
"nags.summary-same-as-title.title": {
"defaultMessage": "讓摘要與眾不同"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "你的摘要不應包含排版、換行、特殊字元或連結,因為摘要只會顯示純文字。"
},
"nags.summary-special-formatting.title": {
"defaultMessage": "清理摘要"
},
"nags.summary-too-short.description": {
"defaultMessage": "您的摘要長度為 {length, plural, one {# 個字元} other {# 個字元}}。建議至少輸入 {minChars, plural, one {# 個字元} other {# 個字元}},以建立資訊完整且吸引人的摘要。"
},
"nags.summary-too-short.title": {
"defaultMessage": "擴展摘要"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "保持專案名稱簡潔,能讓它更易於記憶且方便搜尋。版本與載入器資訊會自動顯示在你的專案旁。"
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "清理名稱"
},
"nags.too-many-languages.description": {
"defaultMessage": "你選擇了 {languageCount, plural, other {# 種語言}}。請只列出你的伺服器目前實際支援的語言。"
},
@@ -291,4 +270,3 @@
"defaultMessage": "前往連結設定"
}
}
+31 -19
View File
@@ -2,8 +2,6 @@ import type { Labrinth } from '@modrinth/api-client'
import type { MessageDescriptor } from '@modrinth/ui'
import type { FunctionalComponent, SVGAttributes } from 'vue'
import type { ProjectValidationResult } from '../validators/project-validation/index.ts'
/**
* Type which represents the status type of a nag.
*
@@ -13,12 +11,23 @@ import type { ProjectValidationResult } from '../validators/project-validation/i
*/
export type NagStatus = 'required' | 'warning' | 'suggestion' | 'special-submit-action'
export type NagDestinationId =
| 'description'
| 'disclosures'
| 'gallery'
| 'general'
| 'license'
| 'links'
| 'moderation'
| 'permissions'
| 'server'
| 'tags'
| 'versions'
/**
* Interface representing the context in which a nag is displayed.
* It includes the project, versions, current member, all members, and the current route.
* This context is used to determine whether a nag or it's link should be shown and how it should be presented.
* Data required to validate a project.
*/
export interface NagContext {
export interface ProjectValidationContext {
/**
* The project associated with the nag.
*/
@@ -27,26 +36,29 @@ export interface NagContext {
* The project V3 associated with the nag.
*/
projectV3: Labrinth.Projects.v3.Project
/**
* Validation results for editable project text fields.
*/
projectValidation: ProjectValidationResult
/**
* The versions associated with the project.
*/
versions: Labrinth.Versions.v3.Version[]
tags: {
categories?: Labrinth.Tags.v2.Category[]
rejectedStatuses: string[]
}
}
/**
* Context required to render a nag and its navigation.
*/
export interface NagContext extends ProjectValidationContext {
/**
* The current project member viewing the nag.
*/
currentMember: Labrinth.Users.v2.User
currentMember?: Labrinth.Users.v2.User
/**
* The current route in the application.
*/
currentRoute: string
/* eslint-disable @typescript-eslint/no-explicit-any */
tags: any
submitProject: (...any: any) => any
/* eslint-enable @typescript-eslint/no-explicit-any */
}
/**
@@ -62,9 +74,9 @@ export interface NagLink {
*/
title: MessageDescriptor | string
/**
* The status of the nag, which can be 'required', 'warning', or 'suggestion'.
* Whether to show the link in the current context.
*/
shouldShow?: (context: NagContext) => boolean
shouldShow: (context: NagContext) => boolean
}
/**
@@ -83,7 +95,7 @@ export interface Nag {
* A function that returns the description of the nag.
* It can accept a context to provide dynamic descriptions.
*/
description: MessageDescriptor | ((context: NagContext) => string)
description: MessageDescriptor | ((context: ProjectValidationContext) => string)
/**
* The status of the nag, which can be 'required', 'warning', or 'suggestion'.
*/
@@ -97,7 +109,7 @@ export interface Nag {
/**
* A function that determines whether the nag should be shown based on the context.
*/
shouldShow: (context: NagContext) => boolean
shouldShow: (context: ProjectValidationContext) => boolean
/**
* An optional link associated with the nag.
* If provided, it should be displayed alongside the nag.
@@ -0,0 +1,25 @@
import type { ValidationRuleMatch, ValidationRuleSet } from './types.ts'
// evaluate a set of rules and return the failed rules
export function evaluateRules<Input, Rules extends ValidationRuleSet<Input>>(
input: Input,
rules: Rules,
): ValidationRuleMatch<Extract<keyof Rules, string>>[] {
type RuleCode = Extract<keyof Rules, string>
const codes = Object.keys(rules) as RuleCode[]
return codes.flatMap<ValidationRuleMatch<RuleCode>>((code) => {
const rule = rules[code]
const result = rule.evaluate(input)
if (result.valid) return []
return [
{
code,
message: result.message ?? rule.presentation.message,
rule,
values: result.values ?? {},
},
]
})
}
@@ -0,0 +1,20 @@
export * from './evaluate-rules.ts'
export * from './nag-destinations.ts'
export * from './rules/description.ts'
export * from './rules/disclosures.ts'
export * from './rules/gallery.ts'
export * from './rules/icon.ts'
export * from './rules/license.ts'
export * from './rules/links.ts'
export * from './rules/moderation.ts'
export * from './rules/name.ts'
export * from './rules/permissions.ts'
export * from './rules/server-settings.ts'
export * from './rules/summary.ts'
export * from './rules/tags.ts'
export * from './rules/versions.ts'
export * from './text.ts'
export * from './to-field-messages.ts'
export * from './to-nags.ts'
export * from './types.ts'
export * from './validate-project.ts'
@@ -0,0 +1,109 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { NagDestinationId, NagLink } from '../types/nags.ts'
const messages = defineMessages({
description: {
id: 'nags.settings.description.title',
defaultMessage: 'Visit description settings',
},
disclosures: {
id: 'nags.settings.disclosures.title',
defaultMessage: 'Visit disclosure settings',
},
gallery: {
id: 'nags.gallery.title',
defaultMessage: 'Visit gallery page',
},
general: {
id: 'nags.settings.title',
defaultMessage: 'Visit general settings',
},
license: {
id: 'nags.settings.license.title',
defaultMessage: 'Visit license settings',
},
links: {
id: 'nags.settings.links.title',
defaultMessage: 'Visit links settings',
},
moderation: {
id: 'nags.moderation.title',
defaultMessage: 'Visit moderation thread',
},
permissions: {
id: 'nags.settings.permissions.title',
defaultMessage: 'Visit permissions dashboard',
},
server: {
id: 'nags.server.title',
defaultMessage: 'Visit server settings',
},
tags: {
id: 'nags.settings.tags.title',
defaultMessage: 'Visit tag settings',
},
versions: {
id: 'nags.settings.versions.title',
defaultMessage: 'Visit versions settings',
},
})
export const nagDestinations = {
description: {
path: 'settings/description',
title: messages.description,
shouldShow: (context) => context.currentRoute !== 'type-project-settings-description',
},
disclosures: {
path: 'settings/disclosures',
title: messages.disclosures,
shouldShow: (context) => context.currentRoute !== 'type-project-settings-disclosures',
},
gallery: {
path: 'settings/gallery',
title: messages.gallery,
shouldShow: (context) => context.currentRoute !== 'type-project-settings-gallery',
},
general: {
path: 'settings',
title: messages.general,
shouldShow: (context) =>
!['type-project-settings', 'type-project-settings-general'].includes(context.currentRoute),
},
license: {
path: 'settings/license',
title: messages.license,
shouldShow: (context) => context.currentRoute !== 'type-project-settings-license',
},
links: {
path: 'settings/links',
title: messages.links,
shouldShow: (context) => context.currentRoute !== 'type-project-settings-links',
},
moderation: {
path: 'moderation',
title: messages.moderation,
shouldShow: (context) => context.currentRoute !== 'type-project-moderation',
},
permissions: {
path: 'settings/permissions',
title: messages.permissions,
shouldShow: (context) => context.currentRoute !== 'type-project-settings-permissions',
},
server: {
path: 'settings/server',
title: messages.server,
shouldShow: (context) => context.currentRoute !== 'type-project-settings-server',
},
tags: {
path: 'settings/tags',
title: messages.tags,
shouldShow: (context) => context.currentRoute !== 'type-project-settings-tags',
},
versions: {
path: 'settings/versions',
title: messages.versions,
shouldShow: (context) => context.currentRoute !== 'type-project-settings-versions',
},
} satisfies Record<NagDestinationId, NagLink>
@@ -0,0 +1,268 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { findBlockedProjectContentLink } from '../../validators/links/detection.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import {
evaluateNonStandardText,
evaluateProfanity,
evaluateSlur,
normalizeProjectFieldText,
} from '../text.ts'
import { toFieldMessages } from '../to-field-messages.ts'
import { toNags } from '../to-nags.ts'
import type { FieldValidationMessage, ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
fixDescription: {
id: 'nags.invalid-project-description.title',
defaultMessage: 'Fix the project description',
},
addDescription: {
id: 'nags.add-description.title',
defaultMessage: 'Add a description',
},
expandDescription: {
id: 'nags.description-too-short.title',
defaultMessage: 'Expand the description',
},
shortenHeaders: {
id: 'nags.long-headers.title',
defaultMessage: 'Shorten headers',
},
ensureAccessibility: {
id: 'nags.image-heavy-description.title',
defaultMessage: 'Ensure accessibility',
},
addImageAltText: {
id: 'nags.missing-alt-text.title',
defaultMessage: 'Add image alt text',
},
editDescription: {
id: 'nags.edit-description.title',
defaultMessage: 'Edit description',
},
slur: {
id: 'nags.project-description-slur.description',
defaultMessage: 'Your project cannot contain any slurs. Detected: “{value}”.',
},
profanity: {
id: 'nags.project-description-profanity.description',
defaultMessage: 'Your project cannot contain excessive profanity. Detected: “{value}”.',
},
nonStandardText: {
id: 'nags.project-description-non-standard-text.description',
defaultMessage: 'Non-standard text characters, such as “₮ɆӾ₮”, are not allowed.',
},
bannedLink: {
id: 'nags.project-description-banned-link.description',
defaultMessage: '“{fullUrl}” is not allowed in project descriptions.',
},
required: {
id: 'nags.add-description.description',
defaultMessage:
"A description that clearly describes the project's purpose and function is required.",
},
tooShort: {
id: 'nags.description-too-short.description',
defaultMessage:
'Your description is {length, plural, one {# readable character} other {# readable characters}}. At least {minChars, plural, one {# character} other {# characters}} is recommended to create a clear and informative description.',
},
longHeaders: {
id: 'nags.long-headers.description',
defaultMessage:
'{count, plural, one {# header} other {# headers}} in your description {count, plural, one {is} other {are}} too long. Headers should be concise and act as section titles, not full sentences.',
},
imageHeavy: {
id: 'nags.image-heavy-description.description',
defaultMessage:
'Your Description should contain sufficient plain text or image alt-text, keeping it accessible to those using screen readers or with slow internet connections.',
},
missingAltText: {
id: 'nags.missing-alt-text.description',
defaultMessage:
'Some of your images are missing alt text, which is important for accessibility, especially for visually impaired users.',
},
})
export const DESCRIPTION_MAX_PROFANITY_COUNT = 1
export const DESCRIPTION_NON_STANDARD_TEXT_FAILURE_THRESHOLD = 0.05
export const MIN_DESCRIPTION_CHARS = 200
export const MAX_HEADER_LENGTH = 80
export const MIN_CHARS_PER_IMAGE = 60
export function analyzeHeaderLength(markdown: string): {
hasLongHeaders: boolean
longHeaders: string[]
} {
if (!markdown) return { hasLongHeaders: false, longHeaders: [] }
const withoutCodeBlocks = markdown.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '')
const headers = [...withoutCodeBlocks.matchAll(/^(#{1,3})\s+(.+)$/gm)]
const longHeaders = headers
.map((match) => match[2].trim())
.filter((headerText) => {
const sentences = headerText.split(/[.!?]+/g).filter((sentence) => sentence.trim().length > 0)
return headerText.length > MAX_HEADER_LENGTH || sentences.length > 1
})
return { hasLongHeaders: longHeaders.length > 0, longHeaders }
}
export function countText(markdown: string): number {
if (!markdown) return 0
const withoutCode = markdown.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '')
const withoutImagesAndLinks = withoutCode
.replace(/!\[[^\]]*]\([^)]+\)/g, ' ')
.replace(/\[[^\]]*]\([^)]+\)/g, ' ')
const withoutHtml = withoutImagesAndLinks.replace(/<[^>]+>/g, ' ')
const withoutMarkdownSyntax = withoutHtml
.replace(/^(?:>[ \t]?)+/gm, '')
.replace(/^#{1,6}\s+/gm, ' ')
.replace(/[*_~`>-]/g, ' ')
.replace(/\|/g, ' ')
return withoutMarkdownSyntax.replace(/\s+/g, ' ').trim().length
}
export function analyzeImageContent(markdown: string): {
imageHeavy: boolean
hasEmptyAltText: boolean
} {
if (!markdown) return { imageHeavy: false, hasEmptyAltText: false }
const withoutCodeBlocks = markdown.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '')
const images = [...withoutCodeBlocks.matchAll(/!\[([^\]]*)\]\([^)]+\)/g)]
const htmlImages = [...withoutCodeBlocks.matchAll(/<img[^>]*>/gi)]
const totalImages = images.length + htmlImages.length
if (totalImages === 0) return { imageHeavy: false, hasEmptyAltText: false }
const textLength = countText(withoutCodeBlocks)
const recommendedTextLength = MIN_CHARS_PER_IMAGE * totalImages
const imageHeavy =
recommendedTextLength > MIN_DESCRIPTION_CHARS && textLength < recommendedTextLength
const hasEmptyAltText =
images.some((match) => !match[1]?.trim()) ||
htmlImages.some((match) => {
const altMatch = match[0].match(/alt\s*=\s*["']([^"']*)["']/i)
return !altMatch || !altMatch[1]?.trim()
})
return { imageHeavy, hasEmptyAltText }
}
type DescriptionInput = string | null | undefined
const commonNagPresentation = {
destination: 'description',
linkTitle: messages.editDescription,
} as const
export const projectDescriptionValidationRules = {
'project-description-slur': {
severity: 'error',
evaluate: (description) => evaluateSlur(description ?? ''),
presentation: {
message: messages.slur,
nag: { title: messages.fixDescription, ...commonNagPresentation },
},
},
'project-description-profanity': {
severity: 'error',
evaluate: (description) =>
evaluateProfanity(description ?? '', DESCRIPTION_MAX_PROFANITY_COUNT),
presentation: {
message: messages.profanity,
nag: { title: messages.fixDescription, ...commonNagPresentation },
},
},
'project-description-non-standard-text': {
severity: 'error',
evaluate: (description) =>
evaluateNonStandardText(description ?? '', DESCRIPTION_NON_STANDARD_TEXT_FAILURE_THRESHOLD),
presentation: {
message: messages.nonStandardText,
nag: { title: messages.fixDescription, ...commonNagPresentation },
},
},
'add-description': {
severity: 'error',
evaluate: (description) => ({
valid: normalizeProjectFieldText(description ?? '').length > 0,
}),
presentation: {
message: messages.required,
nag: { title: messages.addDescription, ...commonNagPresentation },
},
},
'project-description-banned-link': {
severity: 'error',
evaluate: (description) => {
const blockedLink = findBlockedProjectContentLink(description ?? '')
return blockedLink ? { valid: false, values: { fullUrl: blockedLink.url } } : { valid: true }
},
presentation: {
message: messages.bannedLink,
nag: { title: messages.fixDescription, ...commonNagPresentation },
},
},
'description-too-short': {
severity: 'warning',
evaluate: (description) => {
const normalized = normalizeProjectFieldText(description ?? '')
if (!normalized) return { valid: true }
const length = countText(normalized)
return length < MIN_DESCRIPTION_CHARS
? { valid: false, values: { length, minChars: MIN_DESCRIPTION_CHARS } }
: { valid: true }
},
presentation: {
message: messages.tooShort,
nag: { title: messages.expandDescription, ...commonNagPresentation },
},
},
'long-headers': {
severity: 'warning',
evaluate: (description) => {
const { longHeaders } = analyzeHeaderLength(description ?? '')
return longHeaders.length > 0
? { valid: false, values: { count: longHeaders.length } }
: { valid: true }
},
presentation: {
message: messages.longHeaders,
nag: { title: messages.shortenHeaders, ...commonNagPresentation },
},
},
'image-heavy-description': {
severity: 'warning',
evaluate: (description) => ({
valid: !analyzeImageContent(description ?? '').imageHeavy,
}),
presentation: {
message: messages.imageHeavy,
nag: { title: messages.ensureAccessibility, ...commonNagPresentation },
},
},
'missing-alt-text': {
severity: 'warning',
evaluate: (description) => ({
valid: !analyzeImageContent(description ?? '').hasEmptyAltText,
}),
presentation: {
message: messages.missingAltText,
nag: { title: messages.addImageAltText, ...commonNagPresentation },
},
},
} satisfies ValidationRuleSet<DescriptionInput>
export function validateProjectDescription(
description: DescriptionInput,
): FieldValidationMessage[] {
return toFieldMessages(evaluateRules(description, projectDescriptionValidationRules))
}
export function getDescriptionNags(context: Pick<ProjectValidationContext, 'projectV3'>): Nag[] {
return toNags(evaluateRules(context.projectV3.description, projectDescriptionValidationRules))
}
@@ -0,0 +1,43 @@
import { formatProjectTypeSentence } from '@modrinth/ui'
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { toNags } from '../to-nags.ts'
import type { ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
title: {
id: 'nags.check-disclosures.title',
defaultMessage: 'Check content disclosures',
},
description: {
id: 'nags.check-disclosures.description',
defaultMessage:
'Make sure users are aware of any important details by filling in content disclosures that apply to your {type}.',
},
})
export const projectDisclosureValidationRules = {
'check-disclosures': {
severity: 'suggestion',
evaluate: (context) => ({
valid: false,
values: { projectType: context.project.project_type },
}),
presentation: {
message: messages.description,
nag: {
title: messages.title,
destination: 'disclosures',
formatValues: (values, formatMessage) => ({
type: formatProjectTypeSentence(formatMessage, String(values.projectType)),
}),
},
},
},
} satisfies ValidationRuleSet<ProjectValidationContext>
export function getDisclosureNags(context: ProjectValidationContext): Nag[] {
return toNags(evaluateRules(context, projectDisclosureValidationRules))
}
@@ -0,0 +1,170 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { evaluateNonStandardText, evaluateProfanity, evaluateSlur } from '../text.ts'
import { toFieldMessages } from '../to-field-messages.ts'
import { toNags } from '../to-nags.ts'
import type { FieldValidationMessage, ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
uploadImage: {
id: 'nags.upload-gallery-image.title',
defaultMessage: 'Upload a gallery image',
},
uploadResourcePackImage: {
id: 'nags.upload-gallery-image.description-resourcepack',
defaultMessage:
'At least one gallery image is required to showcase the content of your resource pack, except for audio or localization packs. If this describes your pack, please select the appropriate tag.',
},
uploadShaderImages: {
id: 'nags.upload-gallery-image.description-shader',
defaultMessage:
'At least three gallery images are required to showcase the content of your shader in a variety of situations and conditions.',
},
uploadImageDescription: {
id: 'nags.upload-gallery-image.description',
defaultMessage:
'At least one gallery image is required to showcase the content of your {type}.',
},
featureImage: {
id: 'nags.feature-gallery-image.title',
defaultMessage: 'Feature a gallery image',
},
featureImageDescription: {
id: 'nags.feature-gallery-image.description',
defaultMessage:
'The featured gallery image is often how your project makes its first impression.',
},
fixText: {
id: 'nags.invalid-gallery-text.title',
defaultMessage: 'Fix gallery text',
},
editGallery: {
id: 'nags.edit-gallery.title',
defaultMessage: 'Edit gallery',
},
slur: {
id: 'nags.gallery-text-slur.description',
defaultMessage: 'Your gallery cannot contain any slurs. Detected: “{value}”.',
},
profanity: {
id: 'nags.gallery-text-profanity.description',
defaultMessage: 'Your gallery cannot contain excessive profanity. Detected: “{value}”.',
},
nonStandardText: {
id: 'nags.gallery-text-non-standard.description',
defaultMessage: 'Non-standard text characters, such as “₮ɆӾ₮”, are not allowed.',
},
})
type GalleryTextInput = string | null | undefined
export const projectGalleryTextValidationRules = {
'gallery-text-slur': {
severity: 'error',
evaluate: (text) => evaluateSlur(text ?? ''),
presentation: {
message: messages.slur,
nag: {
title: messages.fixText,
destination: 'gallery',
linkTitle: messages.editGallery,
},
},
},
'gallery-text-profanity': {
severity: 'error',
evaluate: (text) => evaluateProfanity(text ?? ''),
presentation: {
message: messages.profanity,
nag: {
title: messages.fixText,
destination: 'gallery',
linkTitle: messages.editGallery,
},
},
},
'gallery-text-non-standard': {
severity: 'error',
evaluate: (text) => evaluateNonStandardText(text ?? ''),
presentation: {
message: messages.nonStandardText,
nag: {
title: messages.fixText,
destination: 'gallery',
linkTitle: messages.editGallery,
},
},
},
} satisfies ValidationRuleSet<GalleryTextInput>
export const projectGalleryValidationRules = {
'upload-gallery-image': {
severity: 'error',
evaluate: (context) => {
const isShader = context.projectV3.project_types.includes('shader')
if (isShader && context.project.gallery && context.project.gallery.length < 3) {
return { valid: false, message: messages.uploadShaderImages }
}
const isResourcePack = context.projectV3.project_types.includes('resourcepack')
const categories = context.project.categories.concat(
context.project.additional_categories ?? [],
)
if (
isResourcePack &&
context.project.gallery &&
context.project.gallery.length === 0 &&
!categories.includes('audio') &&
!categories.includes('locale')
) {
return { valid: false, message: messages.uploadResourcePackImage }
}
return { valid: true }
},
presentation: {
message: messages.uploadImageDescription,
nag: { title: messages.uploadImage, destination: 'gallery' },
},
},
'feature-gallery-image': {
severity: 'suggestion',
evaluate: (context) => ({
valid:
Boolean(context.projectV3.minecraft_server) ||
Boolean(context.project.gallery?.find((image) => image.featured)),
}),
presentation: {
message: messages.featureImageDescription,
nag: { title: messages.featureImage, destination: 'gallery' },
},
},
} satisfies ValidationRuleSet<ProjectValidationContext>
export function validateProjectGalleryName(name: GalleryTextInput): FieldValidationMessage[] {
return toFieldMessages(evaluateRules(name, projectGalleryTextValidationRules))
}
export function validateProjectGalleryDescription(
description: GalleryTextInput,
): FieldValidationMessage[] {
return toFieldMessages(evaluateRules(description, projectGalleryTextValidationRules))
}
export function getGalleryNags(context: ProjectValidationContext): Nag[] {
const galleryNags = toNags(evaluateRules(context, projectGalleryValidationRules))
const textNags = context.projectV3.gallery.flatMap((item, index) => {
const nameNags = toNags(evaluateRules(item.name, projectGalleryTextValidationRules)).map(
(nag) => ({ ...nag, id: `${nag.id}-${index}-name` }),
)
const descriptionNags = toNags(
evaluateRules(item.description, projectGalleryTextValidationRules),
).map((nag) => ({ ...nag, id: `${nag.id}-${index}-description` }))
return [...nameNags, ...descriptionNags]
})
return [...galleryNags, ...textNags]
}
@@ -0,0 +1,33 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { toNags } from '../to-nags.ts'
import type { ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
title: {
id: 'nags.add-icon.title',
defaultMessage: 'Add an icon',
},
description: {
id: 'nags.add-icon.description',
defaultMessage:
'Adding a unique, relevant, and engaging icon makes your project identifiable and helps it stand out.',
},
})
export const projectIconValidationRules = {
'add-icon': {
severity: 'suggestion',
evaluate: (context) => ({ valid: Boolean(context.project.icon_url) }),
presentation: {
message: messages.description,
nag: { title: messages.title, destination: 'general' },
},
},
} satisfies ValidationRuleSet<ProjectValidationContext>
export function getIconNags(context: ProjectValidationContext): Nag[] {
return toNags(evaluateRules(context, projectIconValidationRules))
}
@@ -0,0 +1,122 @@
import { formatProjectTypeSentence } from '@modrinth/ui'
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { getLinkHostname, isInappropriateLicenseLink } from '../../validators/links/index.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { toNags } from '../to-nags.ts'
import type { ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
selectLicense: {
id: 'nags.select-license.title',
defaultMessage: 'Select a license',
},
selectLicenseDescription: {
id: 'nags.select-license.description',
defaultMessage: 'Select the license your {type} is distributed under.',
},
addDetails: {
id: 'nags.add-license-details.title',
defaultMessage: 'Add license details',
},
addDetailsDescription: {
id: 'nags.add-license-details.description',
defaultMessage: 'Add a valid URL and name or SPDX identifier for your custom license.',
},
invalidUrl: {
id: 'nags.invalid-license-url.title',
defaultMessage: 'Add a valid license link',
},
invalidUrlDefault: {
id: 'nags.invalid-license-url.description.default',
defaultMessage: 'License URL is invalid.',
},
invalidUrlDomain: {
id: 'nags.invalid-license-url.description.domain',
defaultMessage:
'Your license URL points to {domain}, which is not appropriate for license information. License URLs should link directly to your license file, not social media, gaming platforms, etc.',
},
invalidUrlMalformed: {
id: 'nags.invalid-license-url.description.malformed',
defaultMessage:
'Your license URL appears to be malformed. Please provide a valid URL to your license text.',
},
editLicense: {
id: 'nags.edit-license.title',
defaultMessage: 'Edit license',
},
})
export const projectLicenseValidationRules = {
'select-license': {
severity: 'error',
evaluate: (context) => {
const licenseId = context.project.license.id
const unknown =
licenseId === 'LicenseRef-Unknown' ||
licenseId === 'NOASSERTION' ||
licenseId === 'LicenseRef-NOASSERTION'
return unknown && !context.projectV3.minecraft_server
? { valid: false, values: { projectType: context.project.project_type } }
: { valid: true }
},
presentation: {
message: messages.selectLicenseDescription,
nag: {
title: messages.selectLicense,
destination: 'license',
formatValues: (values, formatMessage) => ({
type: formatProjectTypeSentence(formatMessage, String(values.projectType)),
}),
},
},
},
'add-custom-license-details': {
severity: 'error',
evaluate: (context) => {
const license = context.project.license
const missingDetails =
license.id === 'LicenseRef-' ||
(license.id.startsWith('LicenseRef-') &&
!license.url &&
license.id !== 'LicenseRef-Unknown' &&
license.id !== 'LicenseRef-All-Rights-Reserved')
return { valid: Boolean(context.projectV3.minecraft_server) || !missingDetails }
},
presentation: {
message: messages.addDetailsDescription,
nag: { title: messages.addDetails, destination: 'license' },
},
},
'invalid-license-url': {
severity: 'error',
evaluate: (context) => {
const licenseUrl = context.project.license.url
if (!licenseUrl) return { valid: true }
const domain = getLinkHostname(licenseUrl)
if (domain && isInappropriateLicenseLink(licenseUrl)) {
return {
valid: false,
message: messages.invalidUrlDomain,
values: { domain },
}
}
if (!domain) return { valid: false, message: messages.invalidUrlMalformed }
return { valid: true }
},
presentation: {
message: messages.invalidUrlDefault,
nag: {
title: messages.invalidUrl,
destination: 'license',
linkTitle: messages.editLicense,
},
},
},
} satisfies ValidationRuleSet<ProjectValidationContext>
export function getLicenseNags(context: ProjectValidationContext): Nag[] {
return toNags(evaluateRules(context, projectLicenseValidationRules))
}
@@ -0,0 +1,231 @@
import { formatProjectTypeSentence } from '@modrinth/ui'
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { licenseRequiresSource, notSourceAsDistributed } from '../../utils.ts'
import {
getBlockedProjectExternalLink,
isCommonProjectLink,
isDiscordLink,
} from '../../validators/links/index.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { toNags } from '../to-nags.ts'
import type { ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
addLinks: {
id: 'nags.add-links.title',
defaultMessage: 'Add external links',
},
addServerLinks: {
id: 'nags.add-links-server.title',
defaultMessage: 'Add external links',
},
addLinksDescription: {
id: 'nags.add-links.description',
defaultMessage:
'Add any relevant links targeted outside of Modrinth, such as source code, an issue tracker, or a Discord invite.',
},
addServerLinksDescription: {
id: 'nags.add-links-server.description',
defaultMessage:
'Add any relevant links targeted outside of Modrinth, such as a website, store, or a Discord invite.',
},
identicalLinks: {
id: 'nags.identical-links.title',
defaultMessage: 'Clean up identical links',
},
identicalLinksDescription: {
id: 'nags.identical-links.description',
defaultMessage:
'Some of your external links appear to be identical. Each link should be entered only once and with the appropriate link type.',
},
verifyLinks: {
id: 'nags.verify-external-links.title',
defaultMessage: 'Verify external links',
},
verifyLinksDescription: {
id: 'nags.verify-external-links.description',
defaultMessage:
'Some of your external links may be using domains that are inappropriate for that type of link.',
},
moveDiscordInvite: {
id: 'nags.misused-discord-link.title',
defaultMessage: 'Move Discord invite',
},
moveDiscordInviteDescription: {
id: 'nags.misused-discord-link-description',
defaultMessage:
'Discord invites can not be used for other link types. Please put your Discord link in the Discord Invite link field only.',
},
removeBannedLinks: {
id: 'nags.banned-link-usage.title',
defaultMessage: 'Remove prohibited links',
},
removeBannedLinksDescription: {
id: 'nags.banned-link-usage.description',
defaultMessage: '“{url}” is not allowed in project links.',
},
provideSource: {
id: 'nags.gpl-license-source-required.title',
defaultMessage: 'Provide source code',
},
provideSourceDescription: {
id: 'nags.gpl-license-source-required.description',
defaultMessage:
'Your {type} uses a license which requires source code to be available. Please provide a source code link or sources file for each additional version, or consider using a different license.',
},
visitLinks: {
id: 'nags.visit-links-settings.title',
defaultMessage: 'Visit links settings',
},
})
export function findBlockedProjectExternalLink(
context: Pick<ProjectValidationContext, 'project' | 'projectV3'>,
) {
const urls = [
context.project.source_url,
context.project.issues_url,
context.project.wiki_url,
context.project.discord_url,
context.project.license.url,
...(context.project.donation_urls ?? []).map(({ url }) => url),
...Object.values(context.projectV3.link_urls ?? {}).map(({ url }) => url),
]
for (const url of urls) {
if (!url) continue
const blockedLink = getBlockedProjectExternalLink(url)
if (blockedLink) return blockedLink
}
return null
}
export const projectLinksValidationRules = {
'add-links': {
severity: 'suggestion',
evaluate: (context) => ({
valid:
Boolean(context.projectV3.minecraft_server) ||
Object.keys(context.projectV3.link_urls ?? {}).length > 0,
}),
presentation: {
message: messages.addLinksDescription,
nag: { title: messages.addLinks, destination: 'links' },
},
},
'add-links-server': {
severity: 'suggestion',
evaluate: (context) => ({
valid:
!context.projectV3.minecraft_server ||
Object.keys(context.projectV3.link_urls ?? {}).length > 0,
}),
presentation: {
message: messages.addServerLinksDescription,
nag: { title: messages.addServerLinks, destination: 'links' },
},
},
'identical-links': {
severity: 'error',
evaluate: (context) => {
const links = Object.values(context.projectV3.link_urls ?? {}).map(({ url }) => url)
return { valid: new Set(links).size === links.length }
},
presentation: {
message: messages.identicalLinksDescription,
nag: { title: messages.identicalLinks, destination: 'links' },
},
},
'verify-external-links': {
severity: 'warning',
evaluate: (context) => {
const sourceUrl = context.project.source_url
const issuesUrl = context.project.issues_url
const discordUrl = context.project.discord_url
return {
valid: !(
(sourceUrl && !isCommonProjectLink(sourceUrl, 'source')) ||
(issuesUrl && !isCommonProjectLink(issuesUrl, 'issues')) ||
(discordUrl && !isCommonProjectLink(discordUrl, 'discord'))
),
}
},
presentation: {
message: messages.verifyLinksDescription,
nag: {
title: messages.verifyLinks,
destination: 'links',
linkTitle: messages.visitLinks,
},
},
},
'misused-discord-link': {
severity: 'error',
evaluate: (context) => ({
valid: !(
isDiscordLink(context.project.source_url) ||
isDiscordLink(context.project.issues_url) ||
isDiscordLink(context.project.wiki_url) ||
isDiscordLink(context.projectV3.link_urls?.site?.url) ||
isDiscordLink(context.projectV3.link_urls?.store?.url)
),
}),
presentation: {
message: messages.moveDiscordInviteDescription,
nag: {
title: messages.moveDiscordInvite,
destination: 'links',
linkTitle: messages.visitLinks,
},
},
},
'banned-link-usage': {
severity: 'error',
evaluate: (context) => {
const blockedLink = findBlockedProjectExternalLink(context)
return blockedLink ? { valid: false, values: { url: blockedLink.url } } : { valid: true }
},
presentation: {
message: messages.removeBannedLinksDescription,
nag: { title: messages.removeBannedLinks },
},
},
'gpl-license-source-required': {
severity: 'error',
evaluate: (context) => {
if (context.projectV3.project_types.includes('datapack')) return { valid: true }
const hasSourceUrl = Boolean(context.project.source_url)
const everyVersionHasAdditionalFiles = context.versions.every(
(version) => version.files.length >= 2,
)
const requiresSource =
licenseRequiresSource(context.projectV3.license.id) &&
notSourceAsDistributed(context.projectV3.project_types) &&
!hasSourceUrl &&
!everyVersionHasAdditionalFiles
return requiresSource
? { valid: false, values: { projectType: context.project.project_type } }
: { valid: true }
},
presentation: {
message: messages.provideSourceDescription,
nag: {
title: messages.provideSource,
destination: 'links',
linkTitle: messages.visitLinks,
formatValues: (values, formatMessage) => ({
type: formatProjectTypeSentence(formatMessage, String(values.projectType)),
}),
},
},
},
} satisfies ValidationRuleSet<ProjectValidationContext>
export function getLinksNags(context: ProjectValidationContext): Nag[] {
return toNags(evaluateRules(context, projectLinksValidationRules))
}
@@ -0,0 +1,34 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { toNags } from '../to-nags.ts'
import type { ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
title: {
id: 'nags.moderator-feedback.title',
defaultMessage: 'Review feedback',
},
description: {
id: 'nags.moderator-feedback.description',
defaultMessage: 'Review and address all concerns from the moderation team before resubmitting.',
},
})
export const projectModerationValidationRules = {
'moderator-feedback': {
severity: 'warning',
evaluate: (context) => ({
valid: !context.tags.rejectedStatuses.includes(context.project.status),
}),
presentation: {
message: messages.description,
nag: { title: messages.title, destination: 'moderation' },
},
},
} satisfies ValidationRuleSet<ProjectValidationContext>
export function getModerationNags(context: ProjectValidationContext): Nag[] {
return toNags(evaluateRules(context, projectModerationValidationRules))
}
@@ -0,0 +1,149 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, NagContext } from '../../types/nags.ts'
import { validateNonStandardText } from '../../validators/non-standard-text/index.ts'
import { validateProfanity } from '../../validators/profanity/index.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { toFieldMessages } from '../to-field-messages.ts'
import { toNags } from '../to-nags.ts'
import type { FieldValidationMessage, ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
fixName: {
id: 'nags.invalid-project-name.title',
defaultMessage: 'Fix the project name',
},
fixVersion: {
id: 'nags.project-name-version.title',
defaultMessage: 'Fix project name',
},
avoidBrandInfringement: {
id: 'nags.minecraft-title-clause.title',
defaultMessage: 'Avoid brand infringement',
},
editName: {
id: 'nags.edit-title.title',
defaultMessage: 'Edit title',
},
slur: {
id: 'nags.project-name-slur.description',
defaultMessage: 'Your project name cannot contain any slurs. Detected: “{value}”.',
},
profanity: {
id: 'nags.project-name-profanity.description',
defaultMessage: 'Your project name cannot contain profanity. Detected: “{value}”.',
},
nonStandardText: {
id: 'nags.project-name-non-standard-text.description',
defaultMessage: 'Non-standard text characters, such as “₮ɆӾ₮”, are not allowed.',
},
versionNumber: {
id: 'project.text-validation.title-version-number',
defaultMessage: 'Names are not allowed to include version numbers.',
},
minecraftBranding: {
id: 'nags.minecraft-title-clause.description',
defaultMessage:
'Projects must not use Minecraft\'s branding or include "Minecraft" as a significant part of the name.',
},
})
export const projectNameValidationRules = {
'project-name-slur': {
severity: 'error',
evaluate: (projectName) => {
const match = validateProfanity(projectName).matches.find((match) => match.kind === 'slur')
if (match) {
return { valid: false, values: { value: match.rawText } }
} else {
return { valid: true }
}
},
presentation: {
message: messages.slur,
nag: {
title: messages.fixName,
destination: 'general',
linkTitle: messages.editName,
},
},
},
'project-name-profanity': {
severity: 'error',
evaluate: (projectName) => {
const match = validateProfanity(projectName).matches.find(
(match) => match.kind === 'profanity',
)
if (match) {
return { valid: false, values: { value: match.rawText } }
} else {
return { valid: true }
}
},
presentation: {
message: messages.profanity,
nag: {
title: messages.fixName,
destination: 'general',
linkTitle: messages.editName,
},
},
},
'project-name-non-standard-text': {
severity: 'error',
evaluate: (projectName) => ({ valid: validateNonStandardText(projectName).valid }),
presentation: {
message: messages.nonStandardText,
nag: {
title: messages.fixName,
destination: 'general',
linkTitle: messages.editName,
},
},
},
'project-name-version': {
severity: 'error',
evaluate: (projectName) => {
const normalizedName = projectName.normalize('NFC').toLowerCase()
const includesVersionNumber = [...normalizedName.matchAll(/\d+(?:\.\d+)+/g)].some((match) => {
const textAfterVersion = normalizedName.slice((match.index ?? 0) + match[0].length)
return !/\b(?:port|fork)\b/.test(textAfterVersion)
})
return { valid: !includesVersionNumber }
},
presentation: {
message: messages.versionNumber,
nag: {
title: messages.fixVersion,
destination: 'general',
linkTitle: messages.editName,
},
},
},
'minecraft-title-clause': {
severity: 'warning',
evaluate: (projectName) => {
const normalizedName = projectName.normalize('NFC').toLowerCase()
const words = normalizedName.split(/\s+/).filter(Boolean)
return {
valid: !(normalizedName.includes('minecraft') && words.length <= 3),
}
},
presentation: {
message: messages.minecraftBranding,
nag: {
title: messages.avoidBrandInfringement,
destination: 'general',
linkTitle: messages.editName,
},
},
},
} satisfies ValidationRuleSet<string>
export function validateProjectNameField(name: string): FieldValidationMessage[] {
return toFieldMessages(evaluateRules(name, projectNameValidationRules))
}
export function getNameNags(context: Pick<NagContext, 'projectV3'>): Nag[] {
return toNags(evaluateRules(context.projectV3.name, projectNameValidationRules))
}
@@ -0,0 +1,37 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { toNags } from '../to-nags.ts'
import type { ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
title: {
id: 'nags.review-permissions.title',
defaultMessage: 'Review external permissions',
},
description: {
id: 'nags.review-permissions.description',
defaultMessage:
'Make sure you have provided proof of your permission to distribute any external content in your Modpack.',
},
})
export const projectPermissionsValidationRules = {
'review-permissions': {
severity: 'error',
evaluate: (context) => ({
valid: !context.versions.some(
(version) => (version.files_missing_attribution?.length ?? 0) >= 1,
),
}),
presentation: {
message: messages.description,
nag: { title: messages.title, destination: 'permissions' },
},
},
} satisfies ValidationRuleSet<ProjectValidationContext>
export function getPermissionsNags(context: ProjectValidationContext): Nag[] {
return toNags(evaluateRules(context, projectPermissionsValidationRules))
}
@@ -0,0 +1,137 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { toNags } from '../to-nags.ts'
import type { ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
selectCountry: {
id: 'nags.select-country.title',
defaultMessage: 'Select a region',
},
selectCountryDescription: {
id: 'nags.select-country.description',
defaultMessage: 'Let players know what region your server is located in.',
},
selectAccurateLanguages: {
id: 'nags.all-languages.title',
defaultMessage: 'Select accurate languages',
},
allLanguages: {
id: 'nags.all-languages.description',
defaultMessage:
"You've selected all available language options. Please list only the languages your server actively supports.",
},
addJavaAddress: {
id: 'nags.add-java-address.title',
defaultMessage: 'Add a Java address',
},
addJavaAddressDescription: {
id: 'nags.add-java-address.description',
defaultMessage: 'Add the IP address and port Java Edition players can use to join your server.',
},
selectCompatibility: {
id: 'nags.select-compatibility.title',
defaultMessage: 'Select compatibility',
},
selectCompatibilityDescription: {
id: 'nags.select-compatibility.description',
defaultMessage:
'Select what versions your server supports, choose a Modpack, or upload your own.',
},
tooManyLanguages: {
id: 'nags.too-many-languages.title',
defaultMessage: 'Select accurate languages',
},
tooManyLanguagesDescription: {
id: 'nags.too-many-languages.description',
defaultMessage:
"You've selected {languageCount, plural, one {# language} other {# languages}}. Please list only the languages your server actively supports.",
},
selectLanguage: {
id: 'nags.select-language.title',
defaultMessage: 'Select a language',
},
selectLanguageDescription: {
id: 'nags.select-language.description',
defaultMessage: 'List the language or languages supported by your server.',
},
})
export const MAX_LANGUAGE_COUNT = 10
export const projectServerSettingsValidationRules = {
'select-country': {
severity: 'error',
evaluate: (context) => ({
valid:
!context.projectV3.minecraft_server || Boolean(context.projectV3.minecraft_server.region),
}),
presentation: {
message: messages.selectCountryDescription,
nag: { title: messages.selectCountry, destination: 'server' },
},
},
'all-languages': {
severity: 'error',
evaluate: () => ({ valid: true }),
presentation: {
message: messages.allLanguages,
nag: { title: messages.selectAccurateLanguages, destination: 'server' },
},
},
'add-java-address': {
severity: 'error',
evaluate: (context) => ({
valid:
!context.projectV3.minecraft_server ||
Boolean(context.projectV3.minecraft_java_server?.address),
}),
presentation: {
message: messages.addJavaAddressDescription,
nag: { title: messages.addJavaAddress, destination: 'server' },
},
},
'select-compatibility': {
severity: 'error',
evaluate: (context) => ({
valid:
context.projectV3.minecraft_java_server?.content?.kind !== 'vanilla' ||
Boolean(context.projectV3.minecraft_java_server.content.recommended_game_version),
}),
presentation: {
message: messages.selectCompatibilityDescription,
nag: { title: messages.selectCompatibility, destination: 'server' },
},
},
'too-many-languages': {
severity: 'warning',
evaluate: (context) => {
const languageCount = context.projectV3.minecraft_server?.languages?.length ?? 0
return languageCount > MAX_LANGUAGE_COUNT
? { valid: false, values: { languageCount } }
: { valid: true }
},
presentation: {
message: messages.tooManyLanguagesDescription,
nag: { title: messages.tooManyLanguages, destination: 'server' },
},
},
'select-language': {
severity: 'suggestion',
evaluate: (context) => ({
valid:
!context.projectV3.minecraft_server ||
(context.projectV3.minecraft_server.languages?.length ?? 0) > 0,
}),
presentation: {
message: messages.selectLanguageDescription,
nag: { title: messages.selectLanguage, destination: 'server' },
},
},
} satisfies ValidationRuleSet<ProjectValidationContext>
export function getServerSettingsNags(context: ProjectValidationContext): Nag[] {
return toNags(evaluateRules(context, projectServerSettingsValidationRules))
}
@@ -0,0 +1,196 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import {
containsExplicitHttpProjectLink,
findBlockedProjectContentLink,
} from '../../validators/links/detection.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import {
evaluateNonStandardText,
evaluateProfanity,
evaluateSlur,
normalizeProjectFieldText,
} from '../text.ts'
import { toFieldMessages } from '../to-field-messages.ts'
import { toNags } from '../to-nags.ts'
import type { FieldValidationMessage, ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
fixSummary: {
id: 'nags.invalid-project-summary.title',
defaultMessage: 'Fix the project summary',
},
reviewSummary: {
id: 'nags.project-summary-content.title',
defaultMessage: 'Review the project summary',
},
expandSummary: {
id: 'nags.summary-too-short.title',
defaultMessage: 'Expand the summary',
},
cleanUpSummary: {
id: 'nags.summary-special-formatting.title',
defaultMessage: 'Clean up the summary',
},
editSummary: {
id: 'nags.edit-summary.title',
defaultMessage: 'Edit summary',
},
slur: {
id: 'nags.project-summary-slur.description',
defaultMessage: 'Your project summary cannot contain any slurs. Detected: “{value}”.',
},
profanity: {
id: 'nags.project-summary-profanity.description',
defaultMessage: 'Your project summary cannot contain profanity. Detected: “{value}”.',
},
nonStandardText: {
id: 'nags.project-summary-non-standard-text.description',
defaultMessage: 'Non-standard text characters, such as “₮ɆӾ₮”, are not allowed.',
},
bannedLink: {
id: 'nags.project-summary-banned-link.description',
defaultMessage: '“{fullUrl}” is not allowed in project summaries.',
},
matchesName: {
id: 'project.text-validation.summary-matches-title',
defaultMessage: "A project summary cannot be the same as it's title.",
},
tooShort: {
id: 'project.text-validation.summary-too-short',
defaultMessage:
'Your summary is {length, plural, one {# character} other {# characters}}. At least {minChars, plural, one {# character} other {# characters}} is recommended to create an informative and enticing summary.',
},
specialFormatting: {
id: 'nags.summary-special-formatting.description',
defaultMessage:
'Your summary should not contain formatting, line breaks, special characters, or links. The summary only displays plain text.',
},
})
export const MIN_SUMMARY_CHARS = 30
export interface ProjectSummaryValidationInput {
summary: string | null | undefined
name: string | null | undefined
}
export function projectSummaryMatchesName(summary: string, name: string) {
const normalizedSummary = normalizeProjectFieldText(summary).replace(/\s+/g, '')
const normalizedName = normalizeProjectFieldText(name).replace(/\s+/g, '')
return normalizedSummary.length > 0 && normalizedSummary === normalizedName
}
export function hasProjectSummaryFormatting(summary: string) {
return Boolean(
summary.match(/# .*/g) ||
summary.match(/---/g) ||
summary.match(/\n/g) ||
summary.match(/`.*`/g) ||
summary.match(/\*.*\*/g) ||
summary.match(/_.*_/g) ||
summary.match(/~~.*~~/g) ||
summary.match(/```/g) ||
summary.match(/> /g),
)
}
const commonNagPresentation = {
destination: 'general',
linkTitle: messages.editSummary,
} as const
export const projectSummaryValidationRules = {
'project-summary-slur': {
severity: 'error',
evaluate: ({ summary }) => evaluateSlur(summary ?? ''),
presentation: {
message: messages.slur,
nag: { title: messages.fixSummary, ...commonNagPresentation },
},
},
'project-summary-profanity': {
severity: 'error',
evaluate: ({ summary }) => evaluateProfanity(summary ?? ''),
presentation: {
message: messages.profanity,
nag: { title: messages.fixSummary, ...commonNagPresentation },
},
},
'project-summary-non-standard-text': {
severity: 'error',
evaluate: ({ summary }) => evaluateNonStandardText(summary ?? ''),
presentation: {
message: messages.nonStandardText,
nag: { title: messages.fixSummary, ...commonNagPresentation },
},
},
'project-summary-banned-link': {
severity: 'error',
evaluate: ({ summary }) => {
const blockedLink = findBlockedProjectContentLink(summary ?? '')
return blockedLink ? { valid: false, values: { fullUrl: blockedLink.url } } : { valid: true }
},
presentation: {
message: messages.bannedLink,
nag: { title: messages.fixSummary, ...commonNagPresentation },
},
},
'project-summary-matches-title': {
severity: 'error',
evaluate: ({ summary, name }) => ({
valid:
!summary ||
containsExplicitHttpProjectLink(summary) ||
!name ||
!projectSummaryMatchesName(summary, name),
}),
presentation: {
message: messages.matchesName,
nag: { title: messages.reviewSummary, ...commonNagPresentation },
},
},
'summary-too-short': {
severity: 'warning',
evaluate: ({ summary }) => {
if (!summary || containsExplicitHttpProjectLink(summary)) return { valid: true }
const length = normalizeProjectFieldText(summary).length
return length < MIN_SUMMARY_CHARS
? { valid: false, values: { length, minChars: MIN_SUMMARY_CHARS } }
: { valid: true }
},
presentation: {
message: messages.tooShort,
nag: { title: messages.expandSummary, ...commonNagPresentation },
},
},
'summary-special-formatting': {
severity: 'error',
evaluate: ({ summary }) => ({
valid:
!summary ||
(!hasProjectSummaryFormatting(summary) && !containsExplicitHttpProjectLink(summary)),
}),
presentation: {
message: messages.specialFormatting,
nag: { title: messages.cleanUpSummary, ...commonNagPresentation },
},
},
} satisfies ValidationRuleSet<ProjectSummaryValidationInput>
export function validateProjectSummary(
input: ProjectSummaryValidationInput,
): FieldValidationMessage[] {
return toFieldMessages(evaluateRules(input, projectSummaryValidationRules))
}
export function getSummaryNags(context: Pick<ProjectValidationContext, 'projectV3'>): Nag[] {
return toNags(
evaluateRules(
{ summary: context.projectV3.summary, name: context.projectV3.name },
projectSummaryValidationRules,
),
)
}
@@ -0,0 +1,188 @@
import type { Labrinth } from '@modrinth/api-client'
import { formatCategory } from '@modrinth/ui'
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { toNags } from '../to-nags.ts'
import type { ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
selectTags: {
id: 'nags.select-tags.title',
defaultMessage: 'Select tags',
},
selectTagsDescription: {
id: 'nags.select-tags.description',
defaultMessage:
'Select the tags that correctly apply to your project to help the right users find it.',
},
selectAccurateTags: {
id: 'nags.too-many-tags.title',
defaultMessage: 'Select accurate tags',
},
selectAccurateServerTags: {
id: 'nags.too-many-tags-server.title',
defaultMessage: 'Select accurate tags',
},
selectAllTags: {
id: 'nags.all-tags-selected.title',
defaultMessage: 'Select accurate tags',
},
tooManyTags: {
id: 'nags.too-many-tags.description',
defaultMessage:
"You've selected {tagCount, plural, one {# tag} other {# tags}}. Consider reducing to {maxTagCount} or fewer to make sure your project appears in relevant search results.",
},
tooManyServerTags: {
id: 'nags.too-many-tags-server.description',
defaultMessage:
"You've selected {tagCount, plural, one {# tag} other {# tags}}. Please reduce to {maxTagCount} or fewer to make sure your server appears in relevant search results.",
},
selectResolution: {
id: 'nags.multiple-resolution-tags.title',
defaultMessage: 'Select correct resolution',
},
multipleResolutionTags: {
id: 'nags.multiple-resolution-tags.description',
defaultMessage:
"You've selected {count, plural, one {# resolution tag} other {# resolution tags}} ({tags}). Resource packs should typically only have one resolution tag that matches their primary resolution.",
},
allTagsSelected: {
id: 'nags.all-tags-selected.description',
defaultMessage:
"You've selected all {totalAvailableTags, plural, one {# available tag} other {# available tags}}. This defeats the purpose of tags, which are meant to help users find relevant projects. Please select only the tags that are relevant to your project.",
},
editTags: {
id: 'nags.edit-tags.title',
defaultMessage: 'Edit tags',
},
})
export const allResolutionTags = ['8x-', '16x', '32x', '48x', '64x', '128x', '256x', '512x+']
export const MAX_TAG_COUNT = 8
export const MAX_TAG_COUNT_SERVER = 18
function getCategories(
project: Labrinth.Projects.v2.Project & { actualProjectType: string },
tags: ProjectValidationContext['tags'],
) {
return (
tags.categories?.filter((category) => category.project_type === project.actualProjectType) ?? []
)
}
function getSelectedTagCount(context: ProjectValidationContext) {
return context.project.categories.length + (context.project.additional_categories?.length ?? 0)
}
function getResolutionTags(context: ProjectValidationContext) {
return context.project.categories
.concat(context.project.additional_categories ?? [])
.filter((tag) => allResolutionTags.includes(tag))
.toSorted((a, b) => allResolutionTags.indexOf(a) - allResolutionTags.indexOf(b))
}
export const projectTagsValidationRules = {
'select-tags': {
severity: 'suggestion',
evaluate: (context) => ({
valid: context.project.versions.length === 0 || context.project.categories.length > 0,
}),
presentation: {
message: messages.selectTagsDescription,
nag: { title: messages.selectTags, destination: 'tags' },
},
},
'too-many-tags': {
severity: 'warning',
evaluate: (context) => {
const tagCount = getSelectedTagCount(context)
const tooMany =
!context.projectV3.minecraft_java_server &&
!context.projectV3.minecraft_server &&
tagCount > MAX_TAG_COUNT
return tooMany
? { valid: false, values: { tagCount, maxTagCount: MAX_TAG_COUNT } }
: { valid: true }
},
presentation: {
message: messages.tooManyTags,
nag: {
title: messages.selectAccurateTags,
destination: 'tags',
linkTitle: messages.editTags,
},
},
},
'too-many-tags-server': {
severity: 'error',
evaluate: (context) => {
const tagCount = getSelectedTagCount(context)
return context.projectV3.minecraft_server && tagCount > MAX_TAG_COUNT_SERVER
? { valid: false, values: { tagCount, maxTagCount: MAX_TAG_COUNT_SERVER } }
: { valid: true }
},
presentation: {
message: messages.tooManyServerTags,
nag: {
title: messages.selectAccurateServerTags,
destination: 'tags',
linkTitle: messages.editTags,
},
},
},
'multiple-resolution-tags': {
severity: 'warning',
evaluate: (context) => {
const resolutionTags = getResolutionTags(context)
return context.project.project_type === 'resourcepack' && resolutionTags.length > 1
? {
valid: false,
values: { count: resolutionTags.length, tags: resolutionTags.join('|') },
}
: { valid: true }
},
presentation: {
message: messages.multipleResolutionTags,
nag: {
title: messages.selectResolution,
destination: 'tags',
linkTitle: messages.editTags,
formatValues: (values, formatMessage) => ({
count: values.count,
tags: String(values.tags)
.split('|')
.map((tag) => formatCategory(formatMessage, tag))
.join(', '),
}),
},
},
},
'all-tags-selected': {
severity: 'error',
evaluate: (context) => {
const categories = getCategories(
context.project as Labrinth.Projects.v2.Project & { actualProjectType: string },
context.tags,
)
const totalAvailableTags = categories.length
const allSelected =
getSelectedTagCount(context) === totalAvailableTags &&
context.project.project_type !== 'project'
return allSelected ? { valid: false, values: { totalAvailableTags } } : { valid: true }
},
presentation: {
message: messages.allTagsSelected,
nag: {
title: messages.selectAllTags,
destination: 'tags',
linkTitle: messages.editTags,
},
},
},
} satisfies ValidationRuleSet<ProjectValidationContext>
export function getTagsNags(context: ProjectValidationContext): Nag[] {
return toNags(evaluateRules(context, projectTagsValidationRules))
}
@@ -0,0 +1,34 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { toNags } from '../to-nags.ts'
import type { ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
title: {
id: 'nags.upload-version.title',
defaultMessage: 'Upload a version',
},
description: {
id: 'nags.upload-version.description',
defaultMessage: 'At least one version is required for a project to be submitted for review.',
},
})
export const projectVersionValidationRules = {
'upload-version': {
severity: 'error',
evaluate: (context) => ({
valid: context.projectV3.versions.length > 0 || Boolean(context.projectV3.minecraft_server),
}),
presentation: {
message: messages.description,
nag: { title: messages.title, destination: 'versions' },
},
},
} satisfies ValidationRuleSet<ProjectValidationContext>
export function getVersionNags(context: ProjectValidationContext): Nag[] {
return toNags(evaluateRules(context, projectVersionValidationRules))
}
@@ -0,0 +1,175 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { evaluateRules } from './evaluate-rules.ts'
import {
countText,
MIN_CHARS_PER_IMAGE,
MIN_DESCRIPTION_CHARS,
validateProjectDescription,
} from './rules/description.ts'
import { validateProjectGalleryDescription, validateProjectGalleryName } from './rules/gallery.ts'
import { projectNameValidationRules, validateProjectNameField } from './rules/name.ts'
import { projectSummaryMatchesName, validateProjectSummary } from './rules/summary.ts'
import { toFieldMessages } from './to-field-messages.ts'
import { toNags } from './to-nags.ts'
import type { ValidationRuleSet } from './types.ts'
test('evaluates matching rules in definition order and converts them to field messages', () => {
const rules = {
'without-values': {
severity: 'error',
evaluate: () => ({ valid: false }),
presentation: {
message: { id: 'without-values' },
nag: { title: { id: 'without-values-title' }, destination: 'general' },
},
},
'with-values': {
severity: 'warning',
evaluate: (value) => ({ valid: false, values: { value } }),
presentation: {
message: { id: 'with-values' },
nag: { title: { id: 'with-values-title' }, destination: 'general' },
},
},
valid: {
severity: 'error',
evaluate: () => ({ valid: true }),
presentation: {
message: { id: 'valid' },
nag: { title: { id: 'valid-title' }, destination: 'general' },
},
},
} satisfies ValidationRuleSet<string>
const matches = evaluateRules('detected', rules)
assert.deepEqual(
matches.map(({ code }) => code),
['without-values', 'with-values'],
)
assert.deepEqual(toFieldMessages(matches), [
{
code: 'without-values',
severity: 'error',
message: { id: 'without-values' },
values: undefined,
},
{
code: 'with-values',
severity: 'warning',
message: { id: 'with-values' },
values: { value: 'detected' },
},
])
})
test('allows clean project names and versioned ports', () => {
assert.deepEqual(evaluateRules('Sodium Extras', projectNameValidationRules), [])
assert.deepEqual(evaluateRules('Sodium 1.20 Port', projectNameValidationRules), [])
})
test('collects every matching project name rule', () => {
const matches = evaluateRules('Minecraft 1.20 fuck', projectNameValidationRules)
assert.deepEqual(
matches.map(({ code }) => code),
['project-name-profanity', 'project-name-version', 'minecraft-title-clause'],
)
assert.deepEqual(matches[0]?.values, { value: 'fuck' })
assert.equal(matches[1]?.rule.severity, 'error')
assert.equal(matches[2]?.rule.severity, 'warning')
})
test('derives project name field presentation from the matching rule', () => {
assert.deepEqual(validateProjectNameField('Minecraft'), [
{
code: 'minecraft-title-clause',
severity: 'warning',
message: {
id: 'nags.minecraft-title-clause.description',
defaultMessage:
'Projects must not use Minecraft\'s branding or include "Minecraft" as a significant part of the name.',
},
values: undefined,
},
])
})
test('derives project name nags from the same matching rule', () => {
const [nag] = toNags(evaluateRules('Minecraft', projectNameValidationRules))
assert.equal(nag?.id, 'minecraft-title-clause')
assert.equal(nag?.status, 'warning')
assert.equal(nag?.title.id, 'nags.minecraft-title-clause.title')
assert.equal(nag?.link?.path, 'settings')
assert.equal(nag?.link?.title.id, 'nags.edit-title.title')
})
test('validates summary content from one rule set', () => {
assert.equal(projectSummaryMatchesName(' Caf\u00e9 ', 'Cafe\u0301'), true)
assert.deepEqual(
validateProjectSummary({ summary: '# Short summary', name: 'Project title' }).map(
({ code, severity }) => ({ code, severity }),
),
[
{ code: 'summary-too-short', severity: 'warning' },
{ code: 'summary-special-formatting', severity: 'error' },
],
)
assert.deepEqual(
validateProjectSummary({
summary: 'A detailed summary of this excellent project',
name: 'Project title',
}),
[],
)
})
test('validates description requirements and simultaneous recommendations', () => {
assert.deepEqual(
validateProjectDescription(' ').map(({ code }) => code),
['add-description'],
)
const description = `${'# '.concat('A'.repeat(81))}\n![](one.png)\n![](two.png)\n![](three.png)\n![](four.png)`
assert.deepEqual(
validateProjectDescription(description).map(({ code }) => code),
['description-too-short', 'long-headers', 'image-heavy-description', 'missing-alt-text'],
)
})
test('counts blockquote content as readable description text', () => {
assert.equal(countText('> Quoted text'), 'Quoted text'.length)
const quotedDescription = `> ${'A'.repeat(MIN_DESCRIPTION_CHARS)}`
assert.equal(
validateProjectDescription(quotedDescription).some(
({ code }) => code === 'description-too-short',
),
false,
)
const images = ['![One](one.png)', '![Two](two.png)', '![Three](three.png)', '![Four](four.png)']
const quotedImageDescription = [
`> ${'A'.repeat(MIN_CHARS_PER_IMAGE * images.length)}`,
...images,
].join('\n')
assert.equal(
validateProjectDescription(quotedImageDescription).some(
({ code }) => code === 'image-heavy-description',
),
false,
)
})
test('uses the gallery text rules for names and descriptions', () => {
assert.deepEqual(
validateProjectGalleryName('This is $h!t').map(({ code }) => code),
['gallery-text-profanity'],
)
assert.deepEqual(
validateProjectGalleryDescription('𝐁').map(({ code }) => code),
['gallery-text-non-standard'],
)
})
@@ -0,0 +1,36 @@
import {
getNonStandardTextRatio,
validateNonStandardText,
} from '../validators/non-standard-text/index.ts'
import { validateProfanity } from '../validators/profanity/index.ts'
import type { ValidationRuleEvaluation } from './types.ts'
export function normalizeProjectFieldText(value: string) {
return value.trim().normalize('NFC')
}
export function evaluateSlur(text: string): ValidationRuleEvaluation {
const match = validateProfanity(text).matches.find((match) => match.kind === 'slur')
return match ? { valid: false, values: { value: match.rawText } } : { valid: true }
}
export function evaluateProfanity(text: string, maxProfanityCount = 0): ValidationRuleEvaluation {
if (!Number.isInteger(maxProfanityCount) || maxProfanityCount < 0) {
throw new Error('Maximum profanity count must be a non-negative integer')
}
const match = validateProfanity(text).matches.filter((match) => match.kind === 'profanity')[
maxProfanityCount
]
return match ? { valid: false, values: { value: match.rawText } } : { valid: true }
}
export function evaluateNonStandardText(
text: string,
failureThreshold = 0,
): ValidationRuleEvaluation {
const result = validateNonStandardText(text)
return {
valid: result.valid || getNonStandardTextRatio(text, result) < failureThreshold,
}
}
@@ -0,0 +1,10 @@
import type { FieldValidationMessage, ValidationRuleMatch } from './types.ts'
export function toFieldMessages(matches: ValidationRuleMatch[]): FieldValidationMessage[] {
return matches.map((match) => ({
code: match.code,
severity: match.rule.severity,
message: match.message,
values: Object.keys(match.values).length > 0 ? match.values : undefined,
}))
}
@@ -0,0 +1,41 @@
import { useVIntl } from '@modrinth/ui/i18n'
import type { Nag } from '../types/nags.ts'
import { nagDestinations } from './nag-destinations.ts'
import type { ValidationRuleMatch, ValidationRuleSeverity } from './types.ts'
function toNagStatus(severity: ValidationRuleSeverity): Nag['status'] {
if (severity === 'error') return 'required'
return severity
}
export function toNags(matches: ValidationRuleMatch[]): Nag[] {
return matches.map((match) => {
const presentation = match.rule.presentation
const destination = presentation.nag.destination
? nagDestinations[presentation.nag.destination]
: undefined
return {
id: match.code,
title: presentation.nag.title,
description: () => {
const { formatMessage } = useVIntl()
const values = presentation.nag.formatValues
? presentation.nag.formatValues(match.values, formatMessage)
: match.values
return formatMessage(match.message, values)
},
status: toNagStatus(match.rule.severity),
shouldShow: () => true,
...(destination
? {
link: {
...destination,
title: presentation.nag.linkTitle ?? destination.title,
},
}
: {}),
}
})
}
@@ -0,0 +1,51 @@
import type { MessageDescriptor, VIntlFormatters } from '@modrinth/ui'
import type { NagDestinationId } from '../types/nags.ts'
export type ValidationRuleSeverity = 'error' | 'warning' | 'suggestion'
export type ValidationRuleValues = Record<string, string | number | boolean>
export type ValidationRuleValueFormatter = (
values: ValidationRuleValues,
formatMessage: VIntlFormatters['formatMessage'],
) => ValidationRuleValues
export interface ValidationRulePresentation {
message: MessageDescriptor
nag: {
title: MessageDescriptor
destination?: NagDestinationId
linkTitle?: MessageDescriptor
formatValues?: ValidationRuleValueFormatter
}
}
export interface ValidationRuleDefinition {
severity: ValidationRuleSeverity
presentation: ValidationRulePresentation
}
export type ValidationRuleEvaluation =
| { valid: true }
| { valid: false; message?: MessageDescriptor; values?: ValidationRuleValues }
export interface ValidationRule<Input> extends ValidationRuleDefinition {
evaluate: (input: Input) => ValidationRuleEvaluation
}
export type ValidationRuleSet<Input> = Readonly<Record<string, ValidationRule<Input>>>
export interface ValidationRuleMatch<Code extends string = string> {
code: Code
message: MessageDescriptor
rule: ValidationRuleDefinition
values: ValidationRuleValues
}
export interface FieldValidationMessage {
code: string
severity: ValidationRuleSeverity
message: MessageDescriptor
values?: ValidationRuleValues
}
@@ -0,0 +1,20 @@
import { getNags } from '../data/nags.ts'
import type { Nag, ProjectValidationContext } from '../types/nags.ts'
export interface ProjectValidationResult {
valid: boolean
requiredNags: Nag[]
warningNags: Nag[]
}
export function validateProject(context: ProjectValidationContext): ProjectValidationResult {
const nags = getNags(context)
const requiredNags = nags.filter((nag) => nag.status === 'required')
const warningNags = nags.filter((nag) => nag.status === 'warning')
return {
valid: requiredNags.length === 0,
requiredNags,
warningNags,
}
}
@@ -0,0 +1,34 @@
import LinkifyIt from 'linkify-it'
import { getBlockedProjectContentLink } from './syntax-checks.ts'
const linkify = new LinkifyIt({
fuzzyEmail: false,
fuzzyIP: true,
fuzzyLink: true,
})
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 containsExplicitHttpProjectLink(text: string) {
return (linkify.match(text) ?? []).some((match) => {
const schema = match.schema.toLowerCase()
return schema === 'http:' || schema === 'https:'
})
}
export function findBlockedProjectContentLink(text: string) {
for (const url of extractProjectLinks(text)) {
const blockedLink = getBlockedProjectContentLink(url)
if (blockedLink) return blockedLink
}
return null
}
@@ -1,4 +1,10 @@
export { PROJECT_LINK_BLOCK_LIST } from './block-list.ts'
export {
containsExplicitHttpProjectLink,
containsProjectLinkOrIp,
extractProjectLinks,
findBlockedProjectContentLink,
} from './detection.ts'
export { PROJECT_LINK_DOMAIN_LIST } from './domain-list.ts'
export {
getBlockedProjectContentLink,
@@ -1,466 +0,0 @@
import LinkifyIt from 'linkify-it'
import { getBlockedProjectContentLink } from '../links/index.ts'
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 {
code: ProjectTextValidationCode
severity: 'warn' | 'error'
message: ProjectFieldMessageDescriptor
values?: Record<string, unknown>
}
export type ProjectTextValidationCode =
| 'text-slur'
| 'text-profanity'
| 'text-non-standard'
| 'text-banned-link'
| 'title-version-number'
| 'title-minecraft-branding'
| 'summary-link'
| 'summary-matches-title'
| 'summary-too-short'
| 'summary-special-formatting'
| 'description-required'
| 'description-too-short'
| 'description-long-headers'
| 'description-image-heavy'
| 'description-missing-alt-text'
export interface ProjectTextValidationOptions {
maxProfanityCount?: number
nonStandardTextFailureThreshold?: number
}
export const DESCRIPTION_MAX_PROFANITY_COUNT = 1
export const DESCRIPTION_NON_STANDARD_TEXT_FAILURE_THRESHOLD = 0.05
export const MIN_DESCRIPTION_CHARS = 200
export const MAX_HEADER_LENGTH = 80
export const MIN_CHARS_PER_IMAGE = 60
export const MIN_SUMMARY_CHARS = 30
const messages = defineMessages({
slur: {
id: 'project.text-validation.slur',
defaultMessage: 'The detected slur “{value}” is not allowed.',
},
profanity: {
id: 'project.text-validation.profanity',
defaultMessage: 'The detected profanity “{value}” is not allowed.',
},
descriptionProfanity: {
id: 'project.text-validation.description-profanity',
defaultMessage: 'Excessive profanity is not allowed. Detected: {values}',
},
nonStandardText: {
id: 'project.text-validation.non-standard-text',
defaultMessage: 'Non-standard text characters are not allowed.',
},
bannedLink: {
id: 'project.text-validation.banned-link',
defaultMessage: '“{url}” is not allowed in project summaries or descriptions.',
},
titleVersionNumber: {
id: 'project.text-validation.title-version-number',
defaultMessage: 'Names are not allowed to include version numbers.',
},
titleMinecraftBranding: {
id: 'nags.minecraft-title-clause.description',
defaultMessage:
'Projects must not use Minecraft\'s branding or include "Minecraft" as a significant part of the name.',
},
summaryMatchesTitle: {
id: 'project.text-validation.summary-matches-title',
defaultMessage: "A project summary cannot be the same as it's title.",
},
summaryTooShort: {
id: 'project.text-validation.summary-too-short',
defaultMessage:
'Your summary is {length, plural, one {# character} other {# characters}}. At least {minChars, plural, one {# character} other {# characters}} is recommended to create an informative and enticing summary.',
},
summarySpecialFormatting: {
id: 'nags.summary-special-formatting.description',
defaultMessage:
'Your summary should not contain formatting, line breaks, special characters, or links. The summary only displays plain text.',
},
descriptionRequired: {
id: 'nags.add-description.description',
defaultMessage:
"A description that clearly describes the project's purpose and function is required.",
},
descriptionTooShort: {
id: 'nags.description-too-short.description',
defaultMessage:
'Your description is {length, plural, one {# readable character} other {# readable characters}}. At least {minChars, plural, one {# character} other {# characters}} is recommended to create a clear and informative description.',
},
descriptionLongHeaders: {
id: 'nags.long-headers.description',
defaultMessage:
'{count, plural, one {# header} other {# headers}} in your description {count, plural, one {is} other {are}} too long. Headers should be concise and act as section titles, not full sentences.',
},
descriptionImageHeavy: {
id: 'nags.image-heavy-description.description',
defaultMessage:
'Your Description should contain sufficient plain text or image alt-text, keeping it accessible to those using screen readers or with slow internet connections.',
},
descriptionMissingAltText: {
id: 'nags.missing-alt-text.description',
defaultMessage:
'Some of your images are missing alt text, which is important for accessibility, especially for visually impaired users.',
},
})
const linkify = new LinkifyIt({
fuzzyEmail: false,
fuzzyIP: true,
fuzzyLink: true,
})
function normalizeForSearch(value: string) {
return value.normalize('NFC').toLowerCase()
}
export function normalizeProjectFieldText(value: string) {
return value.trim().normalize('NFC')
}
export function projectSummaryMatchesTitle(summary: string, title: string) {
const normalizedSummary = normalizeProjectFieldText(summary).replace(/\s+/g, '')
const normalizedTitle = normalizeProjectFieldText(title).replace(/\s+/g, '')
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 containsExplicitHttpProjectLink(text: string) {
return (linkify.match(text) ?? []).some((match) => {
const schema = match.schema.toLowerCase()
return schema === 'http:' || schema === 'https:'
})
}
export function findBlockedProjectContentLink(text: string) {
for (const url of extractProjectLinks(text)) {
const blockedLink = getBlockedProjectContentLink(url)
if (blockedLink) return blockedLink
}
return null
}
export function hasProjectSummaryFormatting(summary: string) {
return Boolean(
summary.match(/# .*/g) ||
summary.match(/---/g) ||
summary.match(/\n/g) ||
summary.match(/`.*`/g) ||
summary.match(/\*.*\*/g) ||
summary.match(/_.*_/g) ||
summary.match(/~~.*~~/g) ||
summary.match(/```/g) ||
summary.match(/> /g),
)
}
export function analyzeHeaderLength(markdown: string): {
hasLongHeaders: boolean
longHeaders: string[]
} {
if (!markdown) return { hasLongHeaders: false, longHeaders: [] }
const withoutCodeBlocks = markdown.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '')
const headers = [...withoutCodeBlocks.matchAll(/^(#{1,3})\s+(.+)$/gm)]
const longHeaders = headers
.map((match) => match[2].trim())
.filter((headerText) => {
const sentences = headerText.split(/[.!?]+/g).filter((sentence) => sentence.trim().length > 0)
return headerText.length > MAX_HEADER_LENGTH || sentences.length > 1
})
return {
hasLongHeaders: longHeaders.length > 0,
longHeaders,
}
}
export function countText(markdown: string): number {
if (!markdown) return 0
const withoutCode = markdown.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '')
const withoutImagesAndLinks = withoutCode
.replace(/!\[[^\]]*]\([^)]+\)/g, ' ')
.replace(/\[[^\]]*]\([^)]+\)/g, ' ')
const withoutHtml = withoutImagesAndLinks.replace(/<[^>]+>/g, ' ')
const withoutMarkdownSyntax = withoutHtml
.replace(/^(?:>[ \t]?)+/gm, '')
.replace(/^#{1,6}\s+/gm, ' ')
.replace(/[*_~`>-]/g, ' ')
.replace(/\|/g, ' ')
return withoutMarkdownSyntax.replace(/\s+/g, ' ').trim().length
}
export function analyzeImageContent(markdown: string): {
imageHeavy: boolean
hasEmptyAltText: boolean
} {
if (!markdown) return { imageHeavy: false, hasEmptyAltText: false }
const withoutCodeBlocks = markdown.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '')
const images = [...withoutCodeBlocks.matchAll(/!\[([^\]]*)\]\([^)]+\)/g)]
const htmlImages = [...withoutCodeBlocks.matchAll(/<img[^>]*>/gi)]
const totalImages = images.length + htmlImages.length
if (totalImages === 0) return { imageHeavy: false, hasEmptyAltText: false }
const textLength = countText(withoutCodeBlocks)
const recommendedTextLength = MIN_CHARS_PER_IMAGE * totalImages
const imageHeavy =
recommendedTextLength > MIN_DESCRIPTION_CHARS && textLength < recommendedTextLength
const hasEmptyAltText =
images.some((match) => !match[1]?.trim()) ||
htmlImages.some((match) => {
const altMatch = match[0].match(/alt\s*=\s*["']([^"']*)["']/i)
return !altMatch || !altMatch[1]?.trim()
})
return { imageHeavy, hasEmptyAltText }
}
export function validateProjectText(
text: string | null | undefined,
options: ProjectTextValidationOptions = {},
): ProjectTextValidationResult[] {
if (!text) return []
const profanity = validateProfanity(text)
const slurMatch = profanity.matches.find((match) => match.kind === 'slur')
if (slurMatch) {
return [
{
code: 'text-slur',
severity: 'error',
message: messages.slur,
values: { value: slurMatch.rawText },
},
]
}
const maxProfanityCount = options.maxProfanityCount ?? 0
if (!Number.isInteger(maxProfanityCount) || maxProfanityCount < 0) {
throw new Error('Maximum profanity count must be a non-negative integer')
}
const profanityMatch = profanity.matches.filter((match) => match.kind === 'profanity')[
maxProfanityCount
]
if (profanityMatch) {
return [
{
code: 'text-profanity',
severity: 'error',
message: messages.profanity,
values: { value: profanityMatch.rawText },
},
]
}
const nonStandardText = validateNonStandardText(text)
const nonStandardTextFailureThreshold = options.nonStandardTextFailureThreshold ?? 0
if (
!nonStandardText.valid &&
getNonStandardTextRatio(text, nonStandardText) >= nonStandardTextFailureThreshold
) {
return [{ code: 'text-non-standard', severity: 'error', message: messages.nonStandardText }]
}
return []
}
export function validateProjectTitle(
text: string | null | undefined,
): ProjectTextValidationResult[] {
const results = validateProjectText(text)
if (results.length > 0 || !text) return results
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: 'title-version-number',
severity: 'error',
message: messages.titleVersionNumber,
})
}
const wordsInTitle = normalizedTitle.split(/\s+/).filter(Boolean)
if (normalizedTitle.includes('minecraft') && wordsInTitle.length <= 3) {
results.push({
code: 'title-minecraft-branding',
severity: 'warn',
message: messages.titleMinecraftBranding,
})
}
return results
}
export function validateProjectSummary(
summary: string | null | undefined,
title: string | null | undefined,
): ProjectTextValidationResult[] {
const results = validateProjectText(summary)
if (results.length > 0 || !summary) return results
const blockedLink = findBlockedProjectContentLink(summary)
if (blockedLink) {
return [
{
code: 'text-banned-link',
severity: 'error',
message: messages.bannedLink,
values: blockedLink,
},
]
}
const containsExplicitLink = containsExplicitHttpProjectLink(summary)
if (!containsExplicitLink && title && projectSummaryMatchesTitle(summary, title)) {
return [
{
code: 'summary-matches-title',
severity: 'error',
message: messages.summaryMatchesTitle,
},
]
}
const length = normalizeProjectFieldText(summary).length
if (!containsExplicitLink && length < MIN_SUMMARY_CHARS) {
results.push({
code: 'summary-too-short',
severity: 'warn',
message: messages.summaryTooShort,
values: { length, minChars: MIN_SUMMARY_CHARS },
})
}
if (hasProjectSummaryFormatting(summary) || containsExplicitLink) {
results.push({
code: containsExplicitLink ? 'summary-link' : 'summary-special-formatting',
severity: 'error',
message: messages.summarySpecialFormatting,
})
}
return results
}
export function validateProjectDescription(
description: string | null | undefined,
): ProjectTextValidationResult[] {
const results = validateProjectText(description, {
maxProfanityCount: DESCRIPTION_MAX_PROFANITY_COUNT,
nonStandardTextFailureThreshold: DESCRIPTION_NON_STANDARD_TEXT_FAILURE_THRESHOLD,
})
if (results[0]?.code === 'text-profanity') {
const detectedValues = validateProfanity(description ?? '')
.matches.filter((match) => match.kind === 'profanity')
.map((match) => `"${match.rawText}"`)
.join(', ')
return [
{
...results[0],
message: messages.descriptionProfanity,
values: { values: detectedValues },
},
]
}
if (results.length > 0) return results
const normalizedDescription = normalizeProjectFieldText(description ?? '')
if (!normalizedDescription) {
return [
{
code: 'description-required',
severity: 'error',
message: messages.descriptionRequired,
},
]
}
const blockedLink = findBlockedProjectContentLink(normalizedDescription)
if (blockedLink) {
return [
{
code: 'text-banned-link',
severity: 'error',
message: messages.bannedLink,
values: blockedLink,
},
]
}
const readableLength = countText(normalizedDescription)
if (readableLength < MIN_DESCRIPTION_CHARS) {
results.push({
code: 'description-too-short',
severity: 'warn',
message: messages.descriptionTooShort,
values: { length: readableLength, minChars: MIN_DESCRIPTION_CHARS },
})
}
const { hasLongHeaders, longHeaders } = analyzeHeaderLength(normalizedDescription)
if (hasLongHeaders) {
results.push({
code: 'description-long-headers',
severity: 'warn',
message: messages.descriptionLongHeaders,
values: { count: longHeaders.length },
})
}
const { imageHeavy, hasEmptyAltText } = analyzeImageContent(normalizedDescription)
if (imageHeavy) {
results.push({
code: 'description-image-heavy',
severity: 'warn',
message: messages.descriptionImageHeavy,
})
}
if (hasEmptyAltText) {
results.push({
code: 'description-missing-alt-text',
severity: 'warn',
message: messages.descriptionMissingAltText,
})
}
return results
}
@@ -1,281 +0,0 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
containsExplicitHttpProjectLink,
containsProjectLinkOrIp,
countText,
extractProjectLinks,
MIN_CHARS_PER_IMAGE,
MIN_DESCRIPTION_CHARS,
projectSummaryMatchesTitle,
validateProjectDescription,
validateProjectSummary,
validateProjectText,
validateProjectTitle,
} from './index.ts'
test('compares summaries and titles without whitespace and after Unicode normalization', () => {
assert.equal(projectSummaryMatchesTitle(' Caf\u00e9 ', 'Cafe\u0301'), true)
assert.equal(projectSummaryMatchesTitle('Project summary', 'Projectsummary'), 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('distinguishes explicit HTTP links from bare domains', () => {
assert.equal(containsExplicitHttpProjectLink('Visit https://myserver.com'), true)
assert.equal(containsExplicitHttpProjectLink('Visit HTTP://myserver.com'), true)
assert.equal(containsExplicitHttpProjectLink('Visit myserver.com'), false)
assert.equal(containsExplicitHttpProjectLink('The protocol is https://'), 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.deepEqual(validateProjectText('An ordinary project'), [])
assert.deepEqual(validateProjectText('This project is SHIT')[0], {
code: 'text-profanity',
severity: 'error',
message: {
id: 'project.text-validation.profanity',
defaultMessage: 'The detected profanity “{value}” is not allowed.',
},
values: { value: 'SHIT' },
})
assert.deepEqual(validateProjectText('F.A.G')[0]?.values, { value: 'F.A.G' })
assert.equal(
validateProjectText('𝐅ancy project')[0]?.message.id,
'project.text-validation.non-standard-text',
)
})
test('validates project titles', () => {
assert.deepEqual(validateProjectTitle('Tools 1.2.3'), [
{
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('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 Tools 1.2').map(({ code }) => code),
['title-version-number', 'title-minecraft-branding'],
)
assert.deepEqual(validateProjectTitle('Ordinary Tools'), [])
})
test('validates project summaries', () => {
const summaryContentMessage =
'Your summary should not contain formatting, line breaks, special characters, or links. The summary only displays plain text.'
assert.equal(
validateProjectSummary('Visit https://example.dev', 'Project title')[0]?.message.id,
'nags.summary-special-formatting.description',
)
assert.equal(
validateProjectSummary('Visit https://example.dev', 'Project title')[0]?.message.defaultMessage,
summaryContentMessage,
)
assert.equal(
validateProjectSummary('Visit https://example.dev', 'Project title')[0]?.severity,
'error',
)
assert.equal(
validateProjectSummary('Visit http://example.dev', 'Project title')[0]?.code,
'summary-link',
)
assert.deepEqual(
validateProjectSummary(
'Connect at myserver.com to join our friendly community',
'Project title',
),
[],
)
assert.equal(
validateProjectSummary(' Caf\u00e9 ', 'Cafe\u0301')[0]?.message.id,
'project.text-validation.summary-matches-title',
)
assert.equal(validateProjectSummary(' Caf\u00e9 ', 'Cafe\u0301')[0]?.severity, 'error')
assert.equal(
validateProjectSummary(' Caf\u00e9 ', 'Cafe\u0301')[0]?.message.defaultMessage,
"A project summary cannot be the same as it's title.",
)
assert.deepEqual(validateProjectSummary('Short summary', 'Project title'), [
{
code: 'summary-too-short',
severity: 'warn',
message: {
id: 'project.text-validation.summary-too-short',
defaultMessage:
'Your summary is {length, plural, one {# character} other {# characters}}. At least {minChars, plural, one {# character} other {# characters}} is recommended to create an informative and enticing summary.',
},
values: { length: 13, minChars: 30 },
},
])
assert.deepEqual(
validateProjectSummary('A detailed summary of this excellent project', 'Project title'),
[],
)
assert.deepEqual(
validateProjectSummary('# Short summary', 'Project title').map(({ code }) => code),
['summary-too-short', 'summary-special-formatting'],
)
assert.equal(validateProjectSummary('# Short summary', 'Project title')[1]?.severity, 'error')
assert.equal(
validateProjectSummary('# Short summary', 'Project title')[1]?.message.defaultMessage,
summaryContentMessage,
)
})
test('rejects blocklisted links and IP addresses in summaries and descriptions', () => {
const blockedSummary = validateProjectSummary('Visit https://bit.ly/project', 'Title')
assert.deepEqual(blockedSummary[0], {
code: 'text-banned-link',
severity: 'error',
message: {
id: 'project.text-validation.banned-link',
defaultMessage: '“{url}” is not allowed in project summaries or descriptions.',
},
values: {
label: 'URL shortener',
url: 'https://bit.ly/project',
},
})
const blockedDescription = validateProjectDescription(
`A detailed project description with https://bit.ly/project. ${'More details. '.repeat(20)}`,
)
assert.equal(blockedDescription[0]?.code, 'text-banned-link')
assert.equal(blockedDescription[0]?.values?.label, 'URL shortener')
const blockedIp = validateProjectSummary('Join 127.0.0.1:25565 to play', 'Title')
assert.equal(blockedIp[0]?.code, 'text-banned-link')
assert.equal(blockedIp[0]?.values?.label, 'IP address')
const allowedDescription = validateProjectDescription(
`Read more at https://social.modrinth.com/project. ${'More details. '.repeat(20)}`,
)
assert.equal(
allowedDescription.some(({ code }) => code === 'text-banned-link'),
false,
)
})
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).some(({ code }) => code === 'text-non-standard'),
false,
)
assert.equal(
validateProjectDescription(exactlyFivePercent)[0]?.message.id,
'project.text-validation.non-standard-text',
)
})
test('allows one profanity match in descriptions but rejects a second match or any slur', () => {
const description = 'A detailed project description '.repeat(10)
assert.equal(
validateProjectDescription(`${description} shit`).some(({ code }) => code === 'text-profanity'),
false,
)
assert.equal(
validateProjectDescription(`${description} f.u.c.k`).some(
({ code }) => code === 'text-profanity',
),
false,
)
assert.deepEqual(validateProjectDescription(`${description} shit FUCK`)[0], {
code: 'text-profanity',
severity: 'error',
message: {
id: 'project.text-validation.description-profanity',
defaultMessage: 'Excessive profanity is not allowed. Detected: {values}',
},
values: { values: '"shit", "FUCK"' },
})
assert.deepEqual(validateProjectDescription(`${description} shit FUCK bastard`)[0]?.values, {
values: '"shit", "FUCK", "bastard"',
})
assert.equal(validateProjectDescription(`${description} nigga`)[0]?.code, 'text-slur')
assert.equal(validateProjectDescription(`${description} nigger`)[0]?.code, 'text-slur')
})
test('counts blockquote content as readable description text', () => {
assert.equal(countText('> Quoted text'), 'Quoted text'.length)
assert.equal(
countText('> First line\n> > Nested line\n>\n> - Quoted list item'),
'First line Nested line Quoted list item'.length,
)
const quotedDescription = `> ${'A'.repeat(MIN_DESCRIPTION_CHARS)}`
assert.equal(
validateProjectDescription(quotedDescription).some(
({ code }) => code === 'description-too-short',
),
false,
)
const images = ['![One](one.png)', '![Two](two.png)', '![Three](three.png)', '![Four](four.png)']
const quotedImageDescription = [
`> ${'A'.repeat(MIN_CHARS_PER_IMAGE * images.length)}`,
...images,
].join('\n')
assert.equal(
validateProjectDescription(quotedImageDescription).some(
({ code }) => code === 'description-image-heavy',
),
false,
)
})
test('validates required description content and returns simultaneous recommendations', () => {
assert.equal(validateProjectDescription(' ')[0]?.code, 'description-required')
const description = `${'# '.concat('A'.repeat(81))}\n![](one.png)\n![](two.png)\n![](three.png)\n![](four.png)`
assert.deepEqual(
validateProjectDescription(description).map(({ code }) => code),
[
'description-too-short',
'description-long-headers',
'description-image-heavy',
'description-missing-alt-text',
],
)
})
@@ -1,70 +0,0 @@
import type { Labrinth } from '@modrinth/api-client'
import {
type ProjectTextValidationResult,
validateProjectDescription,
validateProjectSummary,
validateProjectText,
validateProjectTitle,
} from '../project-fields/index.ts'
export type ProjectValidationField =
| 'name'
| 'summary'
| 'description'
| 'gallery-name'
| 'gallery-description'
export interface ProjectValidationFailure extends ProjectTextValidationResult {
field: ProjectValidationField
galleryIndex?: number
galleryUrl?: string
}
export interface ProjectValidationResult {
valid: boolean
failures: ProjectValidationFailure[]
}
export function validateProjectFields(
project: Labrinth.Projects.v3.Project,
): ProjectValidationResult {
const failures: ProjectValidationFailure[] = []
function addFailures(
field: ProjectValidationField,
results: ProjectTextValidationResult[],
details: Pick<ProjectValidationFailure, 'galleryIndex' | 'galleryUrl'> = {},
) {
failures.push(
...results.map((result) => ({
...result,
field,
...details,
})),
)
}
addFailures('name', validateProjectTitle(project.name))
addFailures('summary', validateProjectSummary(project.summary, project.name))
addFailures('description', validateProjectDescription(project.description))
project.gallery.forEach((item, galleryIndex) => {
const details = {
galleryIndex,
galleryUrl: item.url,
}
addFailures('gallery-name', validateProjectText(item.name), details)
addFailures('gallery-description', validateProjectText(item.description), details)
})
return {
valid: !failures.some((failure) => failure.severity === 'error'),
failures,
}
}
export function hasProjectFieldValidationFailures(project: Labrinth.Projects.v3.Project): boolean {
return !validateProjectFields(project).valid
}
@@ -1,171 +0,0 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { Labrinth } from '@modrinth/api-client'
import { hasProjectFieldValidationFailures, validateProjectFields } from './index.ts'
function createProject(
overrides: Partial<Labrinth.Projects.v3.Project> = {},
): Labrinth.Projects.v3.Project {
return {
name: 'Ordinary Tools',
summary: 'A collection of ordinary tools.',
description: 'This project adds a collection of ordinary tools. '.repeat(5),
gallery: [],
...overrides,
} as Labrinth.Projects.v3.Project
}
test('validates project fields and gallery text', () => {
const project = createProject({
name: 'Ordinary Tools',
summary: 'Ordinary Tools',
description: '𝐀',
gallery: [
{
url: 'https://cdn.modrinth.com/gallery.png',
raw_url: 'https://cdn.modrinth.com/gallery.png',
featured: false,
name: 'This is $h!t',
description: '𝐁',
created: '2026-01-01T00:00:00Z',
ordering: 0,
},
],
})
const result = validateProjectFields(project)
assert.equal(result.valid, false)
assert.deepEqual(
result.failures.map(({ field, galleryIndex, galleryUrl, message }) => ({
field,
galleryIndex,
galleryUrl,
message: message.id,
})),
[
{
field: 'summary',
galleryIndex: undefined,
galleryUrl: undefined,
message: 'project.text-validation.summary-matches-title',
},
{
field: 'description',
galleryIndex: undefined,
galleryUrl: undefined,
message: 'project.text-validation.non-standard-text',
},
{
field: 'gallery-name',
galleryIndex: 0,
galleryUrl: 'https://cdn.modrinth.com/gallery.png',
message: 'project.text-validation.profanity',
},
{
field: 'gallery-description',
galleryIndex: 0,
galleryUrl: 'https://cdn.modrinth.com/gallery.png',
message: 'project.text-validation.non-standard-text',
},
],
)
assert.deepEqual(result.failures.find(({ field }) => field === 'gallery-name')?.values, {
value: '$h!t',
})
})
test('reports whether a project has field validation failures', () => {
const validProject = createProject()
const invalidProject = createProject({ summary: 'This project is shit' })
assert.deepEqual(validateProjectFields(validProject), {
valid: true,
failures: [],
})
assert.equal(hasProjectFieldValidationFailures(validProject), false)
assert.equal(hasProjectFieldValidationFailures(invalidProject), true)
assert.equal(
hasProjectFieldValidationFailures(
createProject({ name: 'Ordinary Tools', summary: 'Ordinary Tools' }),
),
true,
)
})
test('treats version numbers and explicit summary links as errors', () => {
const project = createProject({
name: 'Tools 1.2.3',
summary: 'Visit https://example.dev for more information',
})
const result = validateProjectFields(project)
assert.equal(result.valid, false)
assert.deepEqual(
result.failures.map(({ code, severity }) => ({ code, severity })),
[
{ code: 'title-version-number', severity: 'error' },
{ code: 'summary-link', severity: 'error' },
],
)
assert.equal(hasProjectFieldValidationFailures(project), true)
assert.equal(
hasProjectFieldValidationFailures(createProject({ name: 'Tools 1.2 Fabric Port' })),
false,
)
})
test('rejects blocklisted links in summaries and descriptions', () => {
const summaryResult = validateProjectFields(
createProject({ summary: 'Visit bit.ly/project for more information' }),
)
assert.equal(summaryResult.valid, false)
assert.equal(summaryResult.failures[0]?.field, 'summary')
assert.equal(summaryResult.failures[0]?.code, 'text-banned-link')
const descriptionResult = validateProjectFields(
createProject({ description: `Visit https://bit.ly/project. ${'More details. '.repeat(20)}` }),
)
assert.equal(descriptionResult.valid, false)
assert.equal(descriptionResult.failures[0]?.field, 'description')
assert.equal(descriptionResult.failures[0]?.code, 'text-banned-link')
})
test('reports summary recommendations without invalidating the project', () => {
const project = createProject({ summary: 'Short summary' })
assert.deepEqual(validateProjectFields(project), {
valid: true,
failures: [
{
code: 'summary-too-short',
field: 'summary',
severity: 'warn',
message: {
id: 'project.text-validation.summary-too-short',
defaultMessage:
'Your summary is {length, plural, one {# character} other {# characters}}. At least {minChars, plural, one {# character} other {# characters}} is recommended to create an informative and enticing summary.',
},
values: { length: 13, minChars: 30 },
},
],
})
assert.equal(hasProjectFieldValidationFailures(project), false)
})
test('treats summary formatting as a field validation failure', () => {
const project = createProject({ summary: '# A formatted project summary' })
const result = validateProjectFields(project)
assert.equal(result.valid, false)
assert.deepEqual(
result.failures.map(({ code, severity }) => ({ code, severity })),
[
{ code: 'summary-too-short', severity: 'warn' },
{ code: 'summary-special-formatting', severity: 'error' },
],
)
assert.equal(hasProjectFieldValidationFailures(project), true)
})