feat: start on i18nifying nags

This commit is contained in:
Calum 2025-07-14 13:19:56 +01:00
parent bddf73119d
commit 35636f7c00
15 changed files with 811 additions and 126 deletions

View File

@ -34,7 +34,7 @@ const enabledLocales: string[] = [];
/**
* Overrides for the categories of the certain locales.
*/
const localesCategoriesOverrides: Partial<Record<string, "fun" | "experimental">> = {
const localesCategoriesOverrides: Partial = {
"en-x-pirate": "fun",
"en-x-updown": "fun",
"en-x-lolcat": "fun",
@ -260,7 +260,13 @@ export default defineNuxtConfig({
const omorphiaLocales: string[] = [];
const omorphiaLocaleSets = new Map<string, { files: { from: string }[] }>();
for await (const localeDir of globIterate("node_modules/@modrinth/ui/src/locales/*", {
const externalLocales = [
"node_modules/@modrinth/ui/src/locales/en-US",
"node_modules/@modrinth/moderation/locales/en-US",
];
for (const localePath of externalLocales) {
for await (const localeDir of globIterate(localePath, {
posix: true,
})) {
const tag = basename(localeDir);
@ -277,6 +283,7 @@ export default defineNuxtConfig({
});
}
}
}
return function resolveLocaleImport(tag: string) {
return omorphiaLocaleSets.get(matchLocale([tag], omorphiaLocales, "en-x-placeholder"));
@ -301,7 +308,7 @@ export default defineNuxtConfig({
format: "crowdin",
});
} else if (fileName === "meta.json") {
const meta: Record<string, { message: string }> = await fs
const meta: Record = await fs
.readFile(localeFile, "utf8")
.then((date) => JSON.parse(date));
const localeMeta = (locale.meta ??= {});

View File

@ -1,6 +1,8 @@
<!-- TODO: After checklist v1.5, move everything into src directory. -->
# @modrinth/moderation
This package contains the moderation checklist system used for reviewing projects on Modrinth. It provides a structured and transparent way to define moderation stages, actions, and messages that are displayed to moderators during the review process.
This package contains both the moderation checklist system used by moderators for reviewing projects on Modrinth, and the publishing checklist (nag system) that provides automated feedback to project authors during the submission process.
## Structure
@ -9,22 +11,31 @@ The package is organized as follows:
```
/packages/moderation/
├── data/
│ ├── checklist.ts # Main checklist definition - imports and exports all stages
│ ├── messages/ # Markdown files containing message templates
│ ├── checklist.ts # Main moderation checklist definition - imports and exports all stages
│ ├── messages/ # Markdown files containing message templates for moderation
│ │ ├── title/ # Messages for the title stage
│ │ ├── description/ # Messages for the description stage
│ │ └── ... # One directory per stage
│ └── stages/ # Stage definition files
│ ├── title.ts # Title stage definition
│ ├── description.ts # Description stage definition
│ └── ... # One file per stage
│ ├── stages/ # Moderation stage definition files
│ │ ├── title.ts # Title stage definition
│ │ ├── description.ts # Description stage definition
│ │ └── ... # One file per stage
│ └── nags/ # Publishing checklist (nag system) files
│ ├── core.ts # Core nags (required fields, basic validation)
│ ├── core.i18n.ts # Internationalization messages for core nags
│ └── ...
└── types/ # Type definitions
├── actions.ts # Action-related types
├── messages.ts # Message-related types
└── stage.ts # Stage-related types
├── actions.ts # Action-related types (moderation)
├── messages.ts # Message-related types (moderation)
├── stage.ts # Stage-related types (moderation)
└── nags.ts # Nag-related types (publishing checklist)
```
## Stages
## Moderation Checklist System
The moderation checklist provides a structured and transparent way to define moderation stages, actions, and messages that are displayed to moderators during the review process.
### Stages
A stage represents a discrete step in the moderation process, like checking a project's title, description, or links. Each stage has:
@ -35,7 +46,7 @@ A stage represents a discrete step in the moderation process, like checking a pr
Stages are defined in individual files in the `data/stages` directory and are assembled into the complete checklist in `data/checklist.ts`.
## Actions
### Actions
Actions represent decisions moderators can make for each stage. They can be buttons, dropdowns, toggles, etc. Actions can have:
@ -47,11 +58,11 @@ Actions represent decisions moderators can make for each stage. They can be butt
Each action requires a unique `id` field that is used for conditional logic and action relationships. The `suggestedStatus` and `severity` fields help determine the overall moderation outcome.
## Messages
### Messages
Messages are the actual text that will be included in communications to project authors. To promote maintainability and reuse, messages are stored as Markdown files in the `data/messages` directory, organized by stage.
### Variable replacement
#### Variable replacement
You can use variables in your messages that will be replaced with user input:
@ -81,11 +92,11 @@ More text after the variable.
The `%MESSAGE%` placeholder will be replaced with the text entered by the moderator.
## Conditional logic
### Conditional logic
The moderation system supports conditional behavior that changes based on the selection of other actions.
### Conditional messages
#### Conditional messages
You can define different messages for an action based on other selected actions:
@ -108,7 +119,7 @@ You can define different messages for an action based on other selected actions:
}
```
### Enabling and disabling actions
#### Enabling and disabling actions
Actions can enable or disable other actions when selected:
@ -131,7 +142,7 @@ Actions can enable or disable other actions when selected:
}
```
### Conditional text inputs
#### Conditional text inputs
Text inputs can be conditionally shown based on selected actions:
@ -147,3 +158,101 @@ relevantExtraInput: [
},
]
```
## Publishing Checklist (Nag System)
The nag system provides automated feedback to project authors during the submission process, helping them improve their projects before they reach moderation. It analyzes project data and provides suggestions, warnings, and requirements.
### Nags
A nag represents a specific issue or suggestion for improvement. Each nag has:
- A unique `id` for identification
- A `title` and `description` displayed to the user
- A `status` indicating severity: `'required'`, `'warning'`, or `'suggestion'`
- A `shouldShow` function that determines when the nag should be displayed
- An optional `link` to help users address the issue
### Internationalization
The nag system uses Vue I18n for internationalization. Each nag category has a corresponding `.i18n.ts` file containing message definitions:
```typescript
// Example from core.i18n.ts
export default defineMessages({
addDescriptionTitle: {
id: 'nags.add-description.title',
defaultMessage: 'Add a description',
},
addDescriptionDescription: {
id: 'nags.add-description.description',
defaultMessage:
"A description that clearly describes the project's purpose and function is required.",
},
})
```
If you want to use context in the messages, you can do so like this:
```typescript
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
return formatMessage(messages.descriptionTooShortDescription, {
length: context.project.body?.length || 0,
minChars: MIN_DESCRIPTION_CHARS,
})
}
```
### Nag Context
The `NagContext` type provides access to:
- `project`: Current project data
- `versions`: Project versions
- `tags`: Frontend "tags" (generated state)
- `currentRoute`: Current page route
- and other data...
### Adding New Nags
To add a new nag:
1. Add the nag definition to the appropriate category file (or make a new category file and add it to `data/nags.ts`)
2. Add corresponding i18n messages to the `.i18n.ts` file
3. Implement the `shouldShow` logic based on project state
4. Add appropriate links to help users resolve the issue
5. Run `pnpm run fix` to fix lint issues & generate the root locale index.json file.
Example:
```typescript
// In description.ts
{
id: 'new-nag',
title: messages.newNagTitle,
description: messages.newNagDescription,
status: 'warning',
shouldShow: (context: NagContext) => {
// Your validation logic here
return someCondition
},
link: {
path: 'settings/description',
title: messages.editDescriptionTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
},
}
```
```typescript
// In description.i18n.ts
newNagTitle: {
id: 'nags.new-nag.title',
defaultMessage: 'New Nag Title',
},
newNagDescription: {
id: 'nags.new-nag.description',
defaultMessage: 'Description of the new nag issue.',
```

View File

@ -0,0 +1,116 @@
import { defineMessages } from '@vintl/vintl'
export default defineMessages({
moderatorFeedbackTitle: {
id: 'nags.moderator-feedback.title',
defaultMessage: 'Review moderator feedback',
},
moderatorFeedbackDescription: {
id: 'nags.moderator-feedback.description',
defaultMessage:
'Review any feedback from moderators regarding your project before resubmitting.',
},
moderationTitle: {
id: 'nags.moderation.title',
defaultMessage: 'Visit moderation thread',
},
uploadVersionTitle: {
id: 'nags.upload-version.title',
defaultMessage: 'Upload a version',
},
uploadVersionDescription: {
id: 'nags.upload-version.description',
defaultMessage: 'At least one version is required for a project to be submitted for review.',
},
versionsTitle: {
id: 'nags.versions.title',
defaultMessage: 'Visit versions page',
},
addDescriptionTitle: {
id: 'nags.add-description.title',
defaultMessage: 'Add a description',
},
addDescriptionDescription: {
id: 'nags.add-description.description',
defaultMessage:
"A description that clearly describes the project's purpose and function is required.",
},
settingsDescriptionTitle: {
id: 'nags.settings.description.title',
defaultMessage: 'Visit description settings',
},
addIconTitle: {
id: 'nags.add-icon.title',
defaultMessage: 'Add an icon',
},
addIconDescription: {
id: 'nags.add-icon.description',
defaultMessage:
'Your project should have a nice-looking icon to uniquely identify your project at a glance.',
},
settingsTitle: {
id: 'nags.settings.title',
defaultMessage: 'Visit general settings',
},
featureGalleryImageTitle: {
id: 'nags.feature-gallery-image.title',
defaultMessage: 'Feature a gallery image',
},
featureGalleryImageDescription: {
id: 'nags.feature-gallery-image.description',
defaultMessage: 'Featured gallery images may be the first impression of many users.',
},
galleryTitle: {
id: 'nags.gallery.title',
defaultMessage: 'Visit gallery page',
},
selectTagsTitle: {
id: 'nags.select-tags.title',
defaultMessage: 'Select tags',
},
selectTagsDescription: {
id: 'nags.select-tags.description',
defaultMessage: 'Select all tags that apply to your project.',
},
settingsTagsTitle: {
id: 'nags.settings.tags.title',
defaultMessage: 'Visit tag settings',
},
addLinksTitle: {
id: 'nags.add-links.title',
defaultMessage: 'Add external links',
},
addLinksDescription: {
id: 'nags.add-links.description',
defaultMessage:
'Add any relevant links targeted outside of Modrinth, such as sources, issues, or a Discord invite.',
},
settingsLinksTitle: {
id: 'nags.settings.links.title',
defaultMessage: 'Visit links settings',
},
selectEnvironmentsTitle: {
id: 'nags.select-environments.title',
defaultMessage: 'Select supported environments',
},
selectEnvironmentsDescription: {
id: 'nags.select-environments.description',
defaultMessage: `Select if the {projectType} functions on the client-side and/or server-side.`,
},
settingsEnvironmentsTitle: {
id: 'nags.settings.environments.title',
defaultMessage: 'Visit general settings',
},
selectLicenseTitle: {
id: 'nags.select-license.title',
defaultMessage: 'Select license',
},
selectLicenseDescription: {
id: 'nags.select-license.description',
defaultMessage: 'Select the license your {projectType} is distributed under.',
},
settingsLicenseTitle: {
id: 'nags.settings.license.title',
defaultMessage: 'Visit license settings',
},
})

View File

@ -1,64 +1,64 @@
import type { Nag, NagContext } from '../../types/nags'
import { formatProjectType } from '@modrinth/utils'
import { useVIntl } from '@vintl/vintl'
import messages from './core.i18n'
export const coreNags: Nag[] = [
{
id: 'moderator-feedback',
title: 'Review moderator feedback',
description: () =>
'Review any feedback from moderators regarding your project before resubmitting.',
title: messages.moderatorFeedbackTitle,
description: messages.moderatorFeedbackDescription,
status: 'suggestion',
shouldShow: (context: NagContext) =>
context.tags.rejectedStatuses.includes(context.project.status),
link: {
path: 'moderation',
title: 'Visit moderation thread',
title: messages.moderationTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-moderation',
},
},
{
id: 'upload-version',
title: 'Upload a version',
description: () => 'At least one version is required for a project to be submitted for review.',
title: messages.uploadVersionTitle,
description: messages.uploadVersionDescription,
status: 'required',
shouldShow: (context: NagContext) => context.versions.length < 1,
link: {
path: 'versions',
title: 'Visit versions page',
title: messages.versionsTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-versions',
},
},
{
id: 'add-description',
title: 'Add a description',
description: () =>
"A description that clearly describes the project's purpose and function is required.",
title: messages.addDescriptionTitle,
description: messages.addDescriptionDescription,
status: 'required',
shouldShow: (context: NagContext) =>
context.project.body === '' || context.project.body.startsWith('# Placeholder description'),
link: {
path: 'settings/description',
title: 'Visit description settings',
title: messages.settingsDescriptionTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
},
},
{
id: 'add-icon',
title: 'Add an icon',
description: () =>
'Your project should have a nice-looking icon to uniquely identify your project at a glance.',
title: messages.addIconTitle,
description: messages.addIconDescription,
status: 'suggestion',
shouldShow: (context: NagContext) => !context.project.icon_url,
link: {
path: 'settings',
title: 'Visit general settings',
title: messages.settingsTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
id: 'feature-gallery-image',
title: 'Feature a gallery image',
description: () => 'Featured gallery images may be the first impression of many users.',
title: messages.featureGalleryImageTitle,
description: messages.featureGalleryImageDescription,
status: 'suggestion',
shouldShow: (context: NagContext) => {
const featuredGalleryImage = context.project.gallery?.find((img) => img.featured)
@ -66,28 +66,27 @@ export const coreNags: Nag[] = [
},
link: {
path: 'gallery',
title: 'Visit gallery page',
title: messages.galleryTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-gallery',
},
},
{
id: 'select-tags',
title: 'Select tags',
description: () => 'Select all tags that apply to your project.',
title: messages.selectTagsTitle,
description: messages.selectTagsDescription,
status: 'suggestion',
shouldShow: (context: NagContext) =>
context.project.versions.length > 0 && context.project.categories.length < 1,
link: {
path: 'settings/tags',
title: 'Visit tag settings',
title: messages.settingsTagsTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-tags',
},
},
{
id: 'add-links',
title: 'Add external links',
description: () =>
'Add any relevant links targeted outside of Modrinth, such as sources, issues, or a Discord invite.',
title: messages.addLinksTitle,
description: messages.addLinksDescription,
status: 'suggestion',
shouldShow: (context: NagContext) =>
!(
@ -99,15 +98,20 @@ export const coreNags: Nag[] = [
),
link: {
path: 'settings/links',
title: 'Visit links settings',
title: messages.settingsLinksTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-links',
},
},
{
id: 'select-environments',
title: 'Select supported environments',
description: (context: NagContext) =>
`Select if the ${formatProjectType(context.project.project_type).toLowerCase()} functions on the client-side and/or server-side.`,
title: messages.selectEnvironmentsTitle,
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
return formatMessage(messages.selectEnvironmentsDescription, {
projectType: formatProjectType(context.project.project_type).toLowerCase(),
})
},
status: 'required',
shouldShow: (context: NagContext) => {
const excludedTypes = ['resourcepack', 'plugin', 'shader', 'datapack']
@ -122,20 +126,25 @@ export const coreNags: Nag[] = [
},
link: {
path: 'settings',
title: 'Visit general settings',
title: messages.settingsEnvironmentsTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
id: 'select-license',
title: 'Select license',
description: (context: NagContext) =>
`Select the license your ${formatProjectType(context.project.project_type).toLowerCase()} is distributed under.`,
title: messages.selectLicenseTitle,
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
return formatMessage(messages.selectLicenseDescription, {
projectType: formatProjectType(context.project.project_type).toLowerCase(),
})
},
status: 'required',
shouldShow: (context: NagContext) => context.project.license.id === 'LicenseRef-Unknown',
link: {
path: 'settings/license',
title: 'Visit license settings',
title: messages.settingsLicenseTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-license',
},
},

View File

@ -0,0 +1,88 @@
import { defineMessages } from '@vintl/vintl'
export default defineMessages({
descriptionTooShortTitle: {
id: 'nags.description-too-short.title',
defaultMessage: 'Description may be insufficient',
},
descriptionTooShortDescription: {
id: 'nags.description-too-short.description',
defaultMessage:
"Your description is {length} characters. It's recommended to have at least {minChars} characters to provide users with enough information about your project.",
},
longHeadersTitle: {
id: 'nags.long-headers.title',
defaultMessage: 'Headers are too long',
},
longHeadersDescription: {
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.',
},
summaryTooShortTitle: {
id: 'nags.summary-too-short.title',
defaultMessage: 'Summary may be insufficient',
},
summaryTooShortDescription: {
id: 'nags.summary-too-short.description',
defaultMessage:
"Your summary is {length} characters. It's recommended to have at least {minChars} characters to provide users with enough information about your project.",
},
minecraftTitleClauseTitle: {
id: 'nags.minecraft-title-clause.title',
defaultMessage: 'Title contains "Minecraft"',
},
minecraftTitleClauseDescription: {
id: 'nags.minecraft-title-clause.description',
defaultMessage:
'Please remove "Minecraft" from your title. You cannot use "Minecraft" in your title for legal reasons.',
},
titleContainsTechnicalInfoTitle: {
id: 'nags.title-contains-technical-info.title',
defaultMessage: 'Title contains loader or version info',
},
titleContainsTechnicalInfoDescription: {
id: 'nags.title-contains-technical-info.description',
defaultMessage:
'Removing these helps keep titles clean and makes your project easier to find. Version and loader information is automatically displayed alongside your project.',
},
summarySameAsTitleTitle: {
id: 'nags.summary-same-as-title.title',
defaultMessage: 'Summary is project name',
},
summarySameAsTitleDescription: {
id: 'nags.summary-same-as-title.description',
defaultMessage:
"Your summary is the same as your project name. Please change it. It's recommended to have a unique summary to provide more context about your project.",
},
imageHeavyDescriptionTitle: {
id: 'nags.image-heavy-description.title',
defaultMessage: 'Description is mostly images',
},
imageHeavyDescriptionDescription: {
id: 'nags.image-heavy-description.description',
defaultMessage:
'Please add more descriptive text to help users understand your project, especially those using screen readers or with slow internet connections.',
},
missingAltTextTitle: {
id: 'nags.missing-alt-text.title',
defaultMessage: 'Images missing alt text',
},
missingAltTextDescription: {
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.',
},
editDescriptionTitle: {
id: 'nags.edit-description.title',
defaultMessage: 'Edit description',
},
editSummaryTitle: {
id: 'nags.edit-summary.title',
defaultMessage: 'Edit summary',
},
editTitleTitle: {
id: 'nags.edit-title.title',
defaultMessage: 'Edit title',
},
})

View File

@ -1,4 +1,7 @@
import type { Nag, NagContext } from '../../types/nags'
import { useVIntl } from '@vintl/vintl'
import messages from './description.i18n'
export const MIN_DESCRIPTION_CHARS = 500
export const MAX_HEADER_LENGTH = 100
@ -70,9 +73,15 @@ function analyzeImageContent(markdown: string): { imageHeavy: boolean; hasEmptyA
export const descriptionNags: Nag[] = [
{
id: 'description-too-short',
title: 'Description may be insufficient',
description: (context: NagContext) =>
`Your description is ${context.project.body?.length || 0} characters. It's recommended to have at least ${MIN_DESCRIPTION_CHARS} characters to provide users with enough information about your project.`,
title: messages.descriptionTooShortTitle,
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
return formatMessage(messages.descriptionTooShortDescription, {
length: context.project.body?.length || 0,
minChars: MIN_DESCRIPTION_CHARS,
})
},
status: 'warning',
shouldShow: (context: NagContext) => {
const bodyLength = context.project.body?.trim()?.length || 0
@ -80,18 +89,21 @@ export const descriptionNags: Nag[] = [
},
link: {
path: 'settings/description',
title: 'Edit description',
title: messages.editDescriptionTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
},
},
{
id: 'long-headers',
title: 'Headers are too long',
title: messages.longHeadersTitle,
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const { longHeaders } = analyzeHeaderLength(context.project.body || '')
const count = longHeaders.length
return `${count} header${count > 1 ? 's' : ''} in your description ${count > 1 ? 'are' : 'is'} too long. Headers should be concise and act as section titles, not full sentences.`
return formatMessage(messages.longHeadersDescription, {
count,
})
},
status: 'warning',
shouldShow: (context: NagContext) => {
@ -100,15 +112,21 @@ export const descriptionNags: Nag[] = [
},
link: {
path: 'settings/description',
title: 'Edit description',
title: messages.editDescriptionTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
},
},
{
id: 'summary-too-short',
title: 'Summary may be insufficient',
description: (context: NagContext) =>
`Your summary is ${context.project.description?.length || 0} characters. It's recommended to have at least ${MIN_SUMMARY_CHARS} characters to provide users with enough information about your project.`,
title: messages.summaryTooShortTitle,
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
return formatMessage(messages.summaryTooShortDescription, {
length: context.project.description?.length || 0,
minChars: MIN_SUMMARY_CHARS,
})
},
status: 'warning',
shouldShow: (context: NagContext) => {
const summaryLength = context.project.description?.trim()?.length || 0
@ -116,15 +134,14 @@ export const descriptionNags: Nag[] = [
},
link: {
path: 'settings',
title: 'Edit summary',
title: messages.editSummaryTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
id: 'minecraft-title-clause',
title: 'Title contains "Minecraft"',
description: () =>
`Please remove "Minecraft" from your title. You cannot use "Minecraft" in your title for legal reasons.`,
title: messages.minecraftTitleClauseTitle,
description: messages.minecraftTitleClauseDescription,
status: 'required',
shouldShow: (context: NagContext) => {
const title = context.project.title?.toLowerCase() || ''
@ -132,16 +149,14 @@ export const descriptionNags: Nag[] = [
},
link: {
path: 'settings',
title: 'Edit title',
title: messages.editTitleTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
id: 'title-contains-technical-info',
title: 'Title contains loader or version info',
description: () => {
return `Removing these helps keep titles clean and makes your project easier to find. Version and loader information is automatically displayed alongside your project.`
},
title: messages.titleContainsTechnicalInfoTitle,
description: messages.titleContainsTechnicalInfoDescription,
status: 'warning',
shouldShow: (context: NagContext) => {
const title = context.project.title?.toLowerCase() || ''
@ -157,15 +172,14 @@ export const descriptionNags: Nag[] = [
},
link: {
path: 'settings',
title: 'Edit title',
title: messages.editTitleTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
id: 'summary-same-as-title',
title: 'Summary is project name',
description: () =>
`Your summary is the same as your project name. Please change it. It's recommended to have a unique summary to provide more context about your project.`,
title: messages.summarySameAsTitleTitle,
description: messages.summarySameAsTitleDescription,
status: 'required',
shouldShow: (context: NagContext) => {
const title = context.project.title?.trim() || ''
@ -174,15 +188,14 @@ export const descriptionNags: Nag[] = [
},
link: {
path: 'settings',
title: 'Edit summary',
title: messages.editSummaryTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
id: 'image-heavy-description',
title: 'Description is mostly images',
description: () =>
`Please add more descriptive text to help users understand your project, especially those using screen readers or with slow internet connections.`,
title: messages.imageHeavyDescriptionTitle,
description: messages.imageHeavyDescriptionDescription,
status: 'warning',
shouldShow: (context: NagContext) => {
const { imageHeavy } = analyzeImageContent(context.project.body || '')
@ -190,15 +203,14 @@ export const descriptionNags: Nag[] = [
},
link: {
path: 'settings/description',
title: 'Edit description',
title: messages.editDescriptionTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
},
},
{
id: 'missing-alt-text',
title: 'Images missing alt text',
description: () =>
`Some of your images are missing alt text, which is important for accessibility, especially for visually impaired users.`,
title: messages.missingAltTextTitle,
description: messages.missingAltTextDescription,
status: 'warning',
shouldShow: (context: NagContext) => {
const { hasEmptyAltText } = analyzeImageContent(context.project.body || '')
@ -206,7 +218,7 @@ export const descriptionNags: Nag[] = [
},
link: {
path: 'settings/description',
title: 'Edit description',
title: messages.editDescriptionTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
},
},

View File

@ -1,3 +1,4 @@
export * from './core'
export * from './links'
export * from './description'
export * from './tags'

View File

@ -0,0 +1,48 @@
import { defineMessages } from '@vintl/vintl'
export default defineMessages({
verifyExternalLinksTitle: {
id: 'nags.verify-external-links.title',
defaultMessage: 'Verify external links',
},
verifyExternalLinksDescription: {
id: 'nags.verify-external-links.description',
defaultMessage:
"Some of your external links may be using domains that aren't recognized as common for their link type.",
},
invalidLicenseUrlTitle: {
id: 'nags.invalid-license-url.title',
defaultMessage: 'Invalid license URL',
},
invalidLicenseUrlDescriptionDefault: {
id: 'nags.invalid-license-url.description.default',
defaultMessage: 'License URL is invalid.',
},
invalidLicenseUrlDescriptionDomain: {
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 to the actual license text or legal documentation, not social media, gaming platforms etc.',
},
invalidLicenseUrlDescriptionMalformed: {
id: 'nags.invalid-license-url.description.malformed',
defaultMessage:
'Your license URL appears to be malformed. Please provide a valid URL to your license text.',
},
gplLicenseSourceRequiredTitle: {
id: 'nags.gpl-license-source-required.title',
defaultMessage: 'GPL license requires source',
},
gplLicenseSourceRequiredDescription: {
id: 'nags.gpl-license-source-required.description',
defaultMessage:
'Your {projectType} uses a GPL license which requires source code to be available. Please provide a source code link or consider using a different license.',
},
visitLinksSettingsTitle: {
id: 'nags.visit-links-settings.title',
defaultMessage: 'Visit links settings',
},
editLicenseTitle: {
id: 'nags.edit-license.title',
defaultMessage: 'Edit license',
},
})

View File

@ -1,5 +1,8 @@
import type { Nag, NagContext } from '../../types/nags'
import { formatProjectType } from '@modrinth/utils'
import { useVIntl } from '@vintl/vintl'
import messages from './links.i18n'
export const commonLinkDomains = {
source: ['github.com', 'gitlab.com', 'bitbucket.org', 'codeberg.org', 'git.sr.ht'],
@ -49,9 +52,8 @@ export function isUncommonLicenseUrl(url: string | undefined, domains: string[])
export const linksNags: Nag[] = [
{
id: 'verify-external-links',
title: 'Verify external links',
description: () =>
`Some of your external links may be using domains that aren't recognized as common for their link type.`,
title: messages.verifyExternalLinksTitle,
description: messages.verifyExternalLinksDescription,
status: 'warning',
shouldShow: (context: NagContext) => {
return (
@ -62,22 +64,26 @@ export const linksNags: Nag[] = [
},
link: {
path: 'settings/links',
title: 'Visit links settings',
title: messages.visitLinksSettingsTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-links',
},
},
{
id: 'invalid-license-url',
title: 'Invalid license URL',
title: messages.invalidLicenseUrlTitle,
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const licenseUrl = context.project.license.url
if (!licenseUrl) return 'License URL is invalid.'
if (!licenseUrl) {
return formatMessage(messages.invalidLicenseUrlDescriptionDefault)
}
try {
const domain = new URL(licenseUrl).hostname.toLowerCase()
return `Your license URL points to ${domain}, which is not appropriate for license information. License URLs should link to the actual license text or legal documentation, not social media, gaming platforms etc.`
return formatMessage(messages.invalidLicenseUrlDescriptionDomain, { domain })
} catch {
return 'Your license URL appears to be malformed. Please provide a valid URL to your license text.'
return formatMessage(messages.invalidLicenseUrlDescriptionMalformed)
}
},
status: 'required',
@ -96,15 +102,20 @@ export const linksNags: Nag[] = [
},
link: {
path: 'settings',
title: 'Edit license',
title: messages.editLicenseTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
id: 'gpl-license-source-required',
title: 'GPL license requires source',
description: (context: NagContext) =>
`Your ${formatProjectType(context.project.project_type).toLowerCase()} uses a GPL license which requires source code to be available. Please provide a source code link or consider using a different license.`,
title: messages.gplLicenseSourceRequiredTitle,
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
return formatMessage(messages.gplLicenseSourceRequiredDescription, {
projectType: formatProjectType(context.project.project_type).toLowerCase(),
})
},
status: 'required',
shouldShow: (context: NagContext) => {
const gplLicenses = [
@ -137,7 +148,7 @@ export const linksNags: Nag[] = [
},
link: {
path: 'settings/links',
title: 'Visit links settings',
title: messages.visitLinksSettingsTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-links',
},
},

View File

@ -0,0 +1,35 @@
import { defineMessages } from '@vintl/vintl'
export default defineMessages({
tooManyTagsTitle: {
id: 'nags.too-many-tags.title',
defaultMessage: 'Too many tags selected',
},
tooManyTagsDescription: {
id: 'nags.too-many-tags.description',
defaultMessage:
"You've selected {tagCount} tags. Consider reducing to 5 or fewer to keep your project focused and easier to discover.",
},
multipleResolutionTagsTitle: {
id: 'nags.multiple-resolution-tags.title',
defaultMessage: 'Multiple resolution tags selected',
},
multipleResolutionTagsDescription: {
id: 'nags.multiple-resolution-tags.description',
defaultMessage:
"You've selected {count} resolution tags ({tags}). Resource packs should typically only have one resolution tag that matches their primary resolution.",
},
allTagsSelectedTitle: {
id: 'nags.all-tags-selected.title',
defaultMessage: 'All tags selected',
},
allTagsSelectedDescription: {
id: 'nags.all-tags-selected.description',
defaultMessage:
"You've selected all {totalAvailableTags} available tags. This defeats the purpose of tags, which are meant to help users find relevant projects. Please select only the tags that truly apply to your project.",
},
editTagsTitle: {
id: 'nags.edit-tags.title',
defaultMessage: 'Edit tags',
},
})

View File

@ -1,5 +1,8 @@
import type { Project } from '@modrinth/utils'
import type { Nag, NagContext } from '../../types/nags'
import { useVIntl } from '@vintl/vintl'
import messages from './tags.i18n'
function getCategories(
project: Project & { actualProjectType: string },
@ -19,32 +22,41 @@ function getCategories(
export const tagsNags: Nag[] = [
{
id: 'too-many-tags',
title: 'Too many tags selected',
title: messages.tooManyTagsTitle,
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const tagCount =
context.project.categories.length + context.project.additional_categories?.length || 0
return `You've selected ${tagCount} tags. Consider reducing to 5 or fewer to keep your project focused and easier to discover.`
context.project.categories.length + (context.project.additional_categories?.length || 0)
return formatMessage(messages.tooManyTagsDescription, {
tagCount,
})
},
status: 'warning',
shouldShow: (context: NagContext) => {
const tagCount =
context.project.categories.length + context.project.additional_categories?.length || 0
context.project.categories.length + (context.project.additional_categories?.length || 0)
return tagCount > 5
},
link: {
path: 'settings/tags',
title: 'Edit tags',
title: messages.editTagsTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-tags',
},
},
{
id: 'multiple-resolution-tags',
title: 'Multiple resolution tags selected',
title: messages.multipleResolutionTagsTitle,
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const resolutionTags = context.project.categories.filter((tag: string) =>
['16x', '32x', '48x', '64x', '128x', '256x', '512x', '1024x'].includes(tag),
)
return `You've selected ${resolutionTags.length} resolution tags (${resolutionTags.join(', ')}). Resource packs should typically only have one resolution tag that matches their primary resolution.`
return formatMessage(messages.multipleResolutionTagsDescription, {
count: resolutionTags.length,
tags: resolutionTags.join(', '),
})
},
status: 'warning',
shouldShow: (context: NagContext) => {
@ -57,20 +69,24 @@ export const tagsNags: Nag[] = [
},
link: {
path: 'settings/tags',
title: 'Edit tags',
title: messages.editTagsTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-tags',
},
},
{
id: 'all-tags-selected',
title: 'All tags selected',
title: messages.allTagsSelectedTitle,
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const categoriesForProjectType = getCategories(
context.project as Project & { actualProjectType: string },
context.tags,
)
const totalAvailableTags = categoriesForProjectType.length
return `You've selected all ${totalAvailableTags} available tags. This defeats the purpose of tags, which are meant to help users find relevant projects. Please select only the tags that truly apply to your project.`
return formatMessage(messages.allTagsSelectedDescription, {
totalAvailableTags,
})
},
status: 'required',
shouldShow: (context: NagContext) => {
@ -84,7 +100,7 @@ export const tagsNags: Nag[] = [
},
link: {
path: 'settings/tags',
title: 'Edit tags',
title: messages.editTagsTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-tags',
},
},

View File

@ -0,0 +1,191 @@
{
"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": "Your project should have a nice-looking icon to uniquely identify your project at a glance."
},
"nags.add-icon.title": {
"defaultMessage": "Add an icon"
},
"nags.add-links.description": {
"defaultMessage": "Add any relevant links targeted outside of Modrinth, such as sources, issues, or a Discord invite."
},
"nags.add-links.title": {
"defaultMessage": "Add external links"
},
"nags.all-tags-selected.description": {
"defaultMessage": "You've selected all {totalAvailableTags} available tags. This defeats the purpose of tags, which are meant to help users find relevant projects. Please select only the tags that truly apply to your project."
},
"nags.all-tags-selected.title": {
"defaultMessage": "All tags selected"
},
"nags.description-too-short.description": {
"defaultMessage": "Your description is {length} characters. It's recommended to have at least {minChars} characters to provide users with enough information about your project."
},
"nags.description-too-short.title": {
"defaultMessage": "Description may be insufficient"
},
"nags.edit-description.title": {
"defaultMessage": "Edit description"
},
"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": "Featured gallery images may be the first impression of many users."
},
"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 {projectType} uses a GPL license which requires source code to be available. Please provide a source code link or consider using a different license."
},
"nags.gpl-license-source-required.title": {
"defaultMessage": "GPL license requires source"
},
"nags.image-heavy-description.description": {
"defaultMessage": "Please add more descriptive text to help users understand your project, especially those using screen readers or with slow internet connections."
},
"nags.image-heavy-description.title": {
"defaultMessage": "Description is mostly images"
},
"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 to the actual license text or legal documentation, 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": "Invalid license URL"
},
"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": "Headers are too long"
},
"nags.minecraft-title-clause.description": {
"defaultMessage": "Please remove \"Minecraft\" from your title. You cannot use \"Minecraft\" in your title for legal reasons."
},
"nags.minecraft-title-clause.title": {
"defaultMessage": "Title contains \"Minecraft\""
},
"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": "Images missing alt text"
},
"nags.moderation.title": {
"defaultMessage": "Visit moderation thread"
},
"nags.moderator-feedback.description": {
"defaultMessage": "Review any feedback from moderators regarding your project before resubmitting."
},
"nags.moderator-feedback.title": {
"defaultMessage": "Review moderator feedback"
},
"nags.multiple-resolution-tags.description": {
"defaultMessage": "You've selected {count} resolution tags ({tags}). Resource packs should typically only have one resolution tag that matches their primary resolution."
},
"nags.multiple-resolution-tags.title": {
"defaultMessage": "Multiple resolution tags selected"
},
"nags.select-environments.description": {
"defaultMessage": "Select if the {projectType} functions on the client-side and/or server-side."
},
"nags.select-environments.title": {
"defaultMessage": "Select supported environments"
},
"nags.select-license.description": {
"defaultMessage": "Select the license your {projectType} is distributed under."
},
"nags.select-license.title": {
"defaultMessage": "Select license"
},
"nags.select-tags.description": {
"defaultMessage": "Select all tags that apply to your project."
},
"nags.select-tags.title": {
"defaultMessage": "Select tags"
},
"nags.settings.description.title": {
"defaultMessage": "Visit description settings"
},
"nags.settings.environments.title": {
"defaultMessage": "Visit general settings"
},
"nags.settings.license.title": {
"defaultMessage": "Visit license settings"
},
"nags.settings.links.title": {
"defaultMessage": "Visit links settings"
},
"nags.settings.tags.title": {
"defaultMessage": "Visit tag settings"
},
"nags.settings.title": {
"defaultMessage": "Visit general settings"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "Your summary is the same as your project name. Please change it. It's recommended to have a unique summary to provide more context about your project."
},
"nags.summary-same-as-title.title": {
"defaultMessage": "Summary is project name"
},
"nags.summary-too-short.description": {
"defaultMessage": "Your summary is {length} characters. It's recommended to have at least {minChars} characters to provide users with enough information about your project."
},
"nags.summary-too-short.title": {
"defaultMessage": "Summary may be insufficient"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "Removing these helps keep titles clean and makes your project easier to find. Version and loader information is automatically displayed alongside your project."
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "Title contains loader or version info"
},
"nags.too-many-tags.description": {
"defaultMessage": "You've selected {tagCount} tags. Consider reducing to 5 or fewer to keep your project focused and easier to discover."
},
"nags.too-many-tags.title": {
"defaultMessage": "Too many tags selected"
},
"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 aren't recognized as common for their link type."
},
"nags.verify-external-links.title": {
"defaultMessage": "Verify external links"
},
"nags.versions.title": {
"defaultMessage": "Visit versions page"
},
"nags.visit-links-settings.title": {
"defaultMessage": "Visit links settings"
}
}

View File

@ -6,14 +6,17 @@
"types": "./index.d.ts",
"scripts": {
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write ."
"fix": "eslint . --fix && prettier --write . && pnpm run intl:extract",
"intl:extract": "formatjs extract \"**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore \"node_modules/**/*\" --out-file locales/en-US/index.json --preserve-whitespace"
},
"dependencies": {
"@modrinth/utils": "workspace:*",
"@modrinth/assets": "workspace:*",
"@modrinth/utils": "workspace:*",
"vue": "^3.5.13"
},
"devDependencies": {
"@formatjs/cli": "^6.2.12",
"@vintl/vintl": "^4.4.1",
"eslint": "^8.57.0",
"eslint-config-custom": "workspace:*",
"tsconfig": "workspace:*"

View File

@ -1,4 +1,5 @@
import type { Project, User, Version } from '@modrinth/utils'
import type { MessageDescriptor } from '@vintl/vintl'
import type { FunctionalComponent, SVGAttributes } from 'vue'
/**
@ -49,7 +50,7 @@ export interface NagLink {
/**
* The text to display for the nag's link.
*/
title: string
title: MessageDescriptor | string
/**
* The status of the nag, which can be 'required', 'warning', or 'suggestion'.
*/
@ -67,12 +68,12 @@ export interface Nag {
/**
* The title of the nag.
*/
title: string
title: MessageDescriptor | string
/**
* A function that returns the description of the nag.
* It can accept a context to provide dynamic descriptions.
*/
description: (context: NagContext) => string
description: MessageDescriptor | ((context: NagContext) => string)
/**
* The status of the nag, which can be 'required', 'warning', or 'suggestion'.
*/

38
pnpm-lock.yaml generated
View File

@ -467,6 +467,12 @@ importers:
specifier: ^3.5.13
version: 3.5.13(typescript@5.8.3)
devDependencies:
'@formatjs/cli':
specifier: ^6.2.12
version: 6.2.12(@vue/compiler-core@3.5.13)(vue@3.5.13(typescript@5.8.3))
'@vintl/vintl':
specifier: ^4.4.1
version: 4.4.1(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3))
eslint:
specifier: ^8.57.0
version: 8.57.0
@ -8898,6 +8904,10 @@ snapshots:
dependencies:
vue: 3.5.13(typescript@5.5.4)
'@braw/async-computed@5.0.2(vue@3.5.13(typescript@5.8.3))':
dependencies:
vue: 3.5.13(typescript@5.8.3)
'@cloudflare/kv-asset-handler@0.3.4':
dependencies:
mime: 3.0.0
@ -9425,6 +9435,11 @@ snapshots:
'@vue/compiler-core': 3.5.13
vue: 3.5.13(typescript@5.5.4)
'@formatjs/cli@6.2.12(@vue/compiler-core@3.5.13)(vue@3.5.13(typescript@5.8.3))':
optionalDependencies:
'@vue/compiler-core': 3.5.13
vue: 3.5.13(typescript@5.8.3)
'@formatjs/ecma402-abstract@1.18.3':
dependencies:
'@formatjs/intl-localematcher': 0.5.4
@ -9482,6 +9497,18 @@ snapshots:
optionalDependencies:
typescript: 5.5.4
'@formatjs/intl@2.10.4(typescript@5.8.3)':
dependencies:
'@formatjs/ecma402-abstract': 2.0.0
'@formatjs/fast-memoize': 2.2.0
'@formatjs/icu-messageformat-parser': 2.7.8
'@formatjs/intl-displaynames': 6.6.8
'@formatjs/intl-listformat': 7.5.7
intl-messageformat: 10.5.14
tslib: 2.6.3
optionalDependencies:
typescript: 5.8.3
'@formatjs/ts-transformer@3.13.14':
dependencies:
'@formatjs/icu-messageformat-parser': 2.7.8
@ -11144,6 +11171,17 @@ snapshots:
transitivePeerDependencies:
- typescript
'@vintl/vintl@4.4.1(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3))':
dependencies:
'@braw/async-computed': 5.0.2(vue@3.5.13(typescript@5.8.3))
'@formatjs/icu-messageformat-parser': 2.7.8
'@formatjs/intl': 2.10.4(typescript@5.8.3)
'@formatjs/intl-localematcher': 0.4.2
intl-messageformat: 10.5.14
vue: 3.5.13(typescript@5.8.3)
transitivePeerDependencies:
- typescript
'@vitejs/plugin-vue-jsx@4.1.1(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0))(vue@3.5.13(typescript@5.5.4))':
dependencies:
'@babel/core': 7.26.0