refactor: migrate to common eslint+prettier configs (#4168)

* refactor: migrate to common eslint+prettier configs

* fix: prettier frontend

* feat: config changes

* fix: lint issues

* fix: lint

* fix: type imports

* fix: cyclical import issue

* fix: lockfile

* fix: missing dep

* fix: switch to tabs

* fix: continue switch to tabs

* fix: rustfmt parity

* fix: moderation lint issue

* fix: lint issues

* fix: ui intl

* fix: lint issues

* Revert "fix: rustfmt parity"

This reverts commit cb99d2376c321d813d4b7fc7e2a213bb30a54711.

* feat: revert last rs
This commit is contained in:
Cal H.
2025-08-14 21:48:38 +01:00
committed by GitHub
parent 82697278dc
commit 2aabcf36ee
702 changed files with 101360 additions and 102020 deletions

View File

@@ -1,7 +0,0 @@
module.exports = {
root: true,
extends: ['custom/library'],
env: {
node: true,
},
}

View File

@@ -0,0 +1 @@
src/locales/**

View File

@@ -147,14 +147,14 @@ Text inputs can be conditionally shown based on selected actions:
```typescript
relevantExtraInput: [
{
label: 'Additional Information',
variable: 'INFO',
showWhen: {
requiredActions: ['specific_action_id'],
excludedActions: ['incompatible_action_id'],
},
},
{
label: 'Additional Information',
variable: 'INFO',
showWhen: {
requiredActions: ['specific_action_id'],
excludedActions: ['incompatible_action_id'],
},
},
]
```

View File

@@ -0,0 +1,2 @@
import config from '@modrinth/tooling-config/eslint/nuxt.mjs'
export default config

View File

@@ -1,23 +1,21 @@
{
"name": "@modrinth/moderation",
"version": "0.0.0",
"private": true,
"main": "./src/index.ts",
"scripts": {
"lint": "eslint . && prettier --check .",
"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 src/locales/en-US/index.json --preserve-whitespace"
},
"dependencies": {
"@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:*"
}
"name": "@modrinth/moderation",
"version": "0.0.0",
"private": true,
"main": "./src/index.ts",
"scripts": {
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore \"node_modules/**/*\" --out-file src/locales/en-US/index.json --preserve-whitespace"
},
"dependencies": {
"@modrinth/assets": "workspace:*",
"@modrinth/utils": "workspace:*",
"vue": "^3.5.13"
},
"devDependencies": {
"@formatjs/cli": "^6.2.12",
"@vintl/vintl": "^4.4.1",
"@modrinth/tooling-config": "workspace:*"
}
}

View File

@@ -1,32 +1,32 @@
import type { Stage } from '../types/stage'
import modpackPermissionsStage from './modpack-permissions-stage'
import categories from './stages/categories'
import reupload from './stages/reupload'
import description from './stages/description'
import gallery from './stages/gallery'
import license from './stages/license'
import links from './stages/links'
import reupload from './stages/reupload'
import ruleFollowing from './stages/rule-following'
import sideTypes from './stages/side-types'
import statusAlerts from './stages/status-alerts'
import summary from './stages/summary'
import titleSlug from './stages/title-slug'
import versions from './stages/versions'
import license from './stages/license'
import undefinedProject from './stages/undefined-project'
import statusAlerts from './stages/status-alerts'
import versions from './stages/versions'
export default [
titleSlug,
summary,
description,
links,
license,
categories,
sideTypes,
gallery,
versions,
reupload,
ruleFollowing,
modpackPermissionsStage,
statusAlerts,
undefinedProject,
titleSlug,
summary,
description,
links,
license,
categories,
sideTypes,
gallery,
versions,
reupload,
ruleFollowing,
modpackPermissionsStage,
statusAlerts,
undefinedProject,
] as ReadonlyArray<Stage>

View File

@@ -1,45 +1,45 @@
import type { KeybindListener } from '../types/keybinds'
const keybinds: KeybindListener[] = [
{
id: 'next-stage',
keybind: 'ArrowRight',
description: 'Go to next stage',
enabled: (ctx) => !ctx.state.isDone && !ctx.state.hasGeneratedMessage,
action: (ctx) => ctx.actions.tryGoNext(),
},
{
id: 'previous-stage',
keybind: 'ArrowLeft',
description: 'Go to previous stage',
enabled: (ctx) => !ctx.state.isDone && !ctx.state.hasGeneratedMessage,
action: (ctx) => ctx.actions.tryGoBack(),
},
{
id: 'generate-message',
keybind: 'Ctrl+Shift+E',
description: 'Generate moderation message',
action: (ctx) => ctx.actions.tryGenerateMessage(),
},
{
id: 'toggle-collapse',
keybind: 'Shift+C',
description: 'Toggle collapse/expand',
action: (ctx) => ctx.actions.tryToggleCollapse(),
},
{
id: 'reset-progress',
keybind: 'Ctrl+Shift+R',
description: 'Reset moderation progress',
action: (ctx) => ctx.actions.tryResetProgress(),
},
{
id: 'skip-project',
keybind: 'Ctrl+Shift+S',
description: 'Skip to next project',
enabled: (ctx) => ctx.state.futureProjectCount > 0 && !ctx.state.isDone,
action: (ctx) => ctx.actions.trySkipProject(),
},
{
id: 'next-stage',
keybind: 'ArrowRight',
description: 'Go to next stage',
enabled: (ctx) => !ctx.state.isDone && !ctx.state.hasGeneratedMessage,
action: (ctx) => ctx.actions.tryGoNext(),
},
{
id: 'previous-stage',
keybind: 'ArrowLeft',
description: 'Go to previous stage',
enabled: (ctx) => !ctx.state.isDone && !ctx.state.hasGeneratedMessage,
action: (ctx) => ctx.actions.tryGoBack(),
},
{
id: 'generate-message',
keybind: 'Ctrl+Shift+E',
description: 'Generate moderation message',
action: (ctx) => ctx.actions.tryGenerateMessage(),
},
{
id: 'toggle-collapse',
keybind: 'Shift+C',
description: 'Toggle collapse/expand',
action: (ctx) => ctx.actions.tryToggleCollapse(),
},
{
id: 'reset-progress',
keybind: 'Ctrl+Shift+R',
description: 'Reset moderation progress',
action: (ctx) => ctx.actions.tryResetProgress(),
},
{
id: 'skip-project',
keybind: 'Ctrl+Shift+S',
description: 'Skip to next project',
enabled: (ctx) => ctx.state.futureProjectCount > 0 && !ctx.state.isDone,
action: (ctx) => ctx.actions.trySkipProject(),
},
]
export default keybinds

View File

@@ -1,32 +1,33 @@
import type { ModerationModpackPermissionApprovalType, Project } from '@modrinth/utils'
import type { Stage } from '../types/stage'
import { PackageOpenIcon } from '@modrinth/assets'
import type { ModerationModpackPermissionApprovalType, Project } from '@modrinth/utils'
import type { Stage } from '../types/stage'
export default {
id: 'modpack-permissions',
title: 'Modpack Permissions',
icon: PackageOpenIcon,
// Replace me please.
guidance_url:
'https://www.notion.so/Content-Moderation-Cheat-Sheets-22d5ee711bf081a4920ef08879fe6bf5?source=copy_link#22d5ee711bf08116bd8bc1186f357062',
shouldShow: (project: Project) => project.project_type === 'modpack',
actions: [
{
id: 'button',
type: 'button',
label: 'This dummy button must be present or the stage will not appear.',
},
],
id: 'modpack-permissions',
title: 'Modpack Permissions',
icon: PackageOpenIcon,
// Replace me please.
guidance_url:
'https://www.notion.so/Content-Moderation-Cheat-Sheets-22d5ee711bf081a4920ef08879fe6bf5?source=copy_link#22d5ee711bf08116bd8bc1186f357062',
shouldShow: (project: Project) => project.project_type === 'modpack',
actions: [
{
id: 'button',
type: 'button',
label: 'This dummy button must be present or the stage will not appear.',
},
],
} as Stage
export const finalPermissionMessages: Record<
ModerationModpackPermissionApprovalType['id'],
string | undefined
ModerationModpackPermissionApprovalType['id'],
string | undefined
> = {
yes: undefined,
'with-attribution-and-source': undefined,
'with-attribution': `The following content has attribution requirements, meaning that you must link back to the page where you originally found this content in your Modpack's description or version changelog (e.g. linking a mod's CurseForge page if you got it from CurseForge):`,
no: 'The following content is not allowed in Modrinth modpacks due to licensing restrictions. Please contact the author(s) directly for permission or remove the content from your modpack:',
'permanent-no': `The following content is not allowed in Modrinth modpacks, regardless of permission obtained. This may be because it breaks Modrinth's content rules or because the authors, upon being contacted for permission, have declined. Please remove the content from your modpack:`,
unidentified: `The following content could not be identified. Please provide proof of its origin along with proof that you have permission to include it:`,
yes: undefined,
'with-attribution-and-source': undefined,
'with-attribution': `The following content has attribution requirements, meaning that you must link back to the page where you originally found this content in your Modpack's description or version changelog (e.g. linking a mod's CurseForge page if you got it from CurseForge):`,
no: 'The following content is not allowed in Modrinth modpacks due to licensing restrictions. Please contact the author(s) directly for permission or remove the content from your modpack:',
'permanent-no': `The following content is not allowed in Modrinth modpacks, regardless of permission obtained. This may be because it breaks Modrinth's content rules or because the authors, upon being contacted for permission, have declined. Please remove the content from your modpack:`,
unidentified: `The following content could not be identified. Please provide proof of its origin along with proof that you have permission to include it:`,
}

View File

@@ -1,292 +1,293 @@
import type { Nag, NagContext } from '../../types/nags'
import { formatProjectType } from '@modrinth/utils'
import { useVIntl, defineMessage } from '@vintl/vintl'
import { defineMessage, useVIntl } from '@vintl/vintl'
import type { Nag, NagContext } from '../../types/nags'
export const coreNags: Nag[] = [
{
id: 'moderator-feedback',
title: defineMessage({
id: 'nags.moderator-feedback.title',
defaultMessage: 'Review moderator 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-id-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.versions.length < 1,
link: {
path: 'versions',
title: defineMessage({
id: 'nags.versions.title',
defaultMessage: 'Visit versions page',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-versions',
},
},
{
id: 'add-description',
title: defineMessage({
id: 'nags.add-description.title',
defaultMessage: 'Add a description',
}),
description: defineMessage({
id: 'nags.add-description.description',
defaultMessage:
"A description that clearly describes the project's purpose and function is required.",
}),
status: 'required',
shouldShow: (context: NagContext) => context.project.body === '',
link: {
path: 'settings/description',
title: defineMessage({
id: 'nags.settings.description.title',
defaultMessage: 'Visit description settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
},
},
{
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-id-settings',
},
},
{
id: 'upload-gallery-image',
title: defineMessage({
id: 'nags.upload-gallery-image.title',
defaultMessage: 'Upload a gallery image',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const projectType = formatProjectType(context.project.project_type).toLowerCase()
let msg = ''
if (context.project.project_type === 'resourcepack') {
msg =
', except for audio or localization packs. If this describes your pack, please select the appropriate tag'
}
const resourcepackMessage = msg
{
id: 'moderator-feedback',
title: defineMessage({
id: 'nags.moderator-feedback.title',
defaultMessage: 'Review moderator 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-id-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.versions.length < 1,
link: {
path: 'versions',
title: defineMessage({
id: 'nags.versions.title',
defaultMessage: 'Visit versions page',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-versions',
},
},
{
id: 'add-description',
title: defineMessage({
id: 'nags.add-description.title',
defaultMessage: 'Add a description',
}),
description: defineMessage({
id: 'nags.add-description.description',
defaultMessage:
"A description that clearly describes the project's purpose and function is required.",
}),
status: 'required',
shouldShow: (context: NagContext) => context.project.body === '',
link: {
path: 'settings/description',
title: defineMessage({
id: 'nags.settings.description.title',
defaultMessage: 'Visit description settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
},
},
{
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-id-settings',
},
},
{
id: 'upload-gallery-image',
title: defineMessage({
id: 'nags.upload-gallery-image.title',
defaultMessage: 'Upload a gallery image',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const projectType = formatProjectType(context.project.project_type).toLowerCase()
let msg = ''
if (context.project.project_type === 'resourcepack') {
msg =
', except for audio or localization packs. If this describes your pack, please select the appropriate tag'
}
const resourcepackMessage = msg
return formatMessage(
defineMessage({
id: 'nags.upload-gallery-image.description',
defaultMessage:
'At least one gallery image is required to showcase the content of your {type}{resourcepackMessage}.',
}),
{
type: projectType,
resourcepackMessage: resourcepackMessage,
},
)
},
status: 'required',
shouldShow: (context: NagContext) => {
return (
(context.project.project_type === 'resourcepack' ||
context.project.project_type === 'shader') &&
(!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')
)
)
},
link: {
path: 'gallery',
title: defineMessage({
id: 'nags.gallery.title',
defaultMessage: 'Visit gallery page',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-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) => {
const featuredGalleryImage = context.project.gallery?.find((img) => img.featured)
return context.project?.gallery?.length === 0 || !featuredGalleryImage
},
link: {
path: 'gallery',
title: defineMessage({
id: 'nags.gallery.title',
defaultMessage: 'Visit gallery page',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-gallery',
},
},
{
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-id-settings-tags',
},
},
{
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) =>
!(
context.project.issues_url ||
context.project.source_url ||
context.project.wiki_url ||
context.project.discord_url ||
context.project.donation_urls.length > 0
),
link: {
path: 'settings/links',
title: defineMessage({
id: 'nags.settings.links.title',
defaultMessage: 'Visit links settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-links',
},
},
{
id: 'select-environments',
title: defineMessage({
id: 'nags.select-environments.title',
defaultMessage: 'Select supported environments',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
return formatMessage(
defineMessage({
id: 'nags.upload-gallery-image.description',
defaultMessage:
'At least one gallery image is required to showcase the content of your {type}{resourcepackMessage}.',
}),
{
type: projectType,
resourcepackMessage: resourcepackMessage,
},
)
},
status: 'required',
shouldShow: (context: NagContext) => {
return (
(context.project.project_type === 'resourcepack' ||
context.project.project_type === 'shader') &&
(!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')
)
)
},
link: {
path: 'gallery',
title: defineMessage({
id: 'nags.gallery.title',
defaultMessage: 'Visit gallery page',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-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) => {
const featuredGalleryImage = context.project.gallery?.find((img) => img.featured)
return context.project?.gallery?.length === 0 || !featuredGalleryImage
},
link: {
path: 'gallery',
title: defineMessage({
id: 'nags.gallery.title',
defaultMessage: 'Visit gallery page',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-gallery',
},
},
{
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-id-settings-tags',
},
},
{
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) =>
!(
context.project.issues_url ||
context.project.source_url ||
context.project.wiki_url ||
context.project.discord_url ||
context.project.donation_urls.length > 0
),
link: {
path: 'settings/links',
title: defineMessage({
id: 'nags.settings.links.title',
defaultMessage: 'Visit links settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-links',
},
},
{
id: 'select-environments',
title: defineMessage({
id: 'nags.select-environments.title',
defaultMessage: 'Select supported environments',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
return formatMessage(
defineMessage({
id: 'nags.select-environments.description',
defaultMessage: `Select if the {projectType} functions on the client-side and/or server-side.`,
}),
{
projectType: formatProjectType(context.project.project_type).toLowerCase(),
},
)
},
status: 'required',
shouldShow: (context: NagContext) => {
const excludedTypes = ['resourcepack', 'plugin', 'shader', 'datapack']
return (
context.project.versions.length > 0 &&
!excludedTypes.includes(context.project.project_type) &&
(context.project.client_side === 'unknown' ||
context.project.server_side === 'unknown' ||
(context.project.client_side === 'unsupported' &&
context.project.server_side === 'unsupported'))
)
},
link: {
path: 'settings',
title: defineMessage({
id: 'nags.settings.environments.title',
defaultMessage: 'Visit general settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
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-environments.description',
defaultMessage: `Select if the {projectType} functions on the client-side and/or server-side.`,
}),
{
projectType: formatProjectType(context.project.project_type).toLowerCase(),
},
)
},
status: 'required',
shouldShow: (context: NagContext) => {
const excludedTypes = ['resourcepack', 'plugin', 'shader', 'datapack']
return (
context.project.versions.length > 0 &&
!excludedTypes.includes(context.project.project_type) &&
(context.project.client_side === 'unknown' ||
context.project.server_side === 'unknown' ||
(context.project.client_side === 'unsupported' &&
context.project.server_side === 'unsupported'))
)
},
link: {
path: 'settings',
title: defineMessage({
id: 'nags.settings.environments.title',
defaultMessage: 'Visit general settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
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 {projectType} is distributed under.',
}),
{
projectType: formatProjectType(context.project.project_type).toLowerCase(),
},
)
},
status: 'required',
shouldShow: (context: NagContext) => context.project.license.id === 'LicenseRef-Unknown',
link: {
path: 'settings/license',
title: defineMessage({
id: 'nags.settings.license.title',
defaultMessage: 'Visit license settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-license',
},
},
return formatMessage(
defineMessage({
id: 'nags.select-license.description',
defaultMessage: 'Select the license your {projectType} is distributed under.',
}),
{
projectType: formatProjectType(context.project.project_type).toLowerCase(),
},
)
},
status: 'required',
shouldShow: (context: NagContext) => context.project.license.id === 'LicenseRef-Unknown',
link: {
path: 'settings/license',
title: defineMessage({
id: 'nags.settings.license.title',
defaultMessage: 'Visit license settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-license',
},
},
]

View File

@@ -1,6 +1,7 @@
import { renderHighlightedString } from '@modrinth/utils'
import { defineMessage, useVIntl } from '@vintl/vintl'
import type { Nag, NagContext } from '../../types/nags'
import { useVIntl, defineMessage } from '@vintl/vintl'
export const MIN_DESCRIPTION_CHARS = 200
export const MAX_HEADER_LENGTH = 80
@@ -8,388 +9,388 @@ export const MIN_SUMMARY_CHARS = 30
export const MIN_CHARS_PER_IMAGE = 60
export function analyzeHeaderLength(markdown: string): {
hasLongHeaders: boolean
longHeaders: string[]
hasLongHeaders: boolean
longHeaders: string[]
} {
if (!markdown) return { hasLongHeaders: false, longHeaders: [] }
if (!markdown) return { hasLongHeaders: false, longHeaders: [] }
const withoutCodeBlocks = markdown.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '')
const withoutCodeBlocks = markdown.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '')
const headerRegex = /^(#{1,3})\s+(.+)$/gm
const headers = [...withoutCodeBlocks.matchAll(headerRegex)]
const headerRegex = /^(#{1,3})\s+(.+)$/gm
const headers = [...withoutCodeBlocks.matchAll(headerRegex)]
const longHeaders: string[] = []
const longHeaders: string[] = []
headers.forEach((match) => {
const headerText = match[2].trim()
const sentenceEnders = /[.!?]+/g
const sentences = headerText.split(sentenceEnders).filter((s) => s.trim().length > 0)
headers.forEach((match) => {
const headerText = match[2].trim()
const sentenceEnders = /[.!?]+/g
const sentences = headerText.split(sentenceEnders).filter((s) => s.trim().length > 0)
const isVeryLong = headerText.length > MAX_HEADER_LENGTH
const hasMultipleSentences = sentences.length > 1
const isVeryLong = headerText.length > MAX_HEADER_LENGTH
const hasMultipleSentences = sentences.length > 1
if (isVeryLong || hasMultipleSentences) {
longHeaders.push(headerText)
}
})
if (isVeryLong || hasMultipleSentences) {
longHeaders.push(headerText)
}
})
return {
hasLongHeaders: longHeaders.length > 0,
longHeaders,
}
return {
hasLongHeaders: longHeaders.length > 0,
longHeaders,
}
}
export function analyzeImageContent(markdown: string): {
imageHeavy: boolean
hasEmptyAltText: boolean
imageHeavy: boolean
hasEmptyAltText: boolean
} {
if (!markdown) return { imageHeavy: false, hasEmptyAltText: false }
if (!markdown) return { imageHeavy: false, hasEmptyAltText: false }
const withoutCodeBlocks = markdown.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '')
const withoutCodeBlocks = markdown.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '')
const imageRegex = /!\[([^\]]*)\]\([^)]+\)/g
const images = [...withoutCodeBlocks.matchAll(imageRegex)]
const imageRegex = /!\[([^\]]*)\]\([^)]+\)/g
const images = [...withoutCodeBlocks.matchAll(imageRegex)]
const htmlImageRegex = /<img[^>]*>/gi
const htmlImages = [...withoutCodeBlocks.matchAll(htmlImageRegex)]
const htmlImageRegex = /<img[^>]*>/gi
const htmlImages = [...withoutCodeBlocks.matchAll(htmlImageRegex)]
const totalImages = images.length + htmlImages.length
if (totalImages === 0) return { imageHeavy: false, hasEmptyAltText: false }
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 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()
})
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 }
return { imageHeavy, hasEmptyAltText }
}
export function countText(markdown: string): number {
if (!markdown) return 0
if (!markdown) return 0
const fallback = (md: string): number => {
const withoutCode = md.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '')
const withoutImagesAndLinks = withoutCode
.replace(/!\[[^\]]*]\([^)]+\)/g, ' ')
.replace(/\[[^\]]*]\([^)]+\)/g, ' ')
const withoutHtml = withoutImagesAndLinks.replace(/<[^>]+>/g, ' ')
const withoutMdSyntax = withoutHtml
.replace(/^>{1}\s?.*$/gm, ' ')
.replace(/^#{1,6}\s+/gm, ' ')
.replace(/[*_~`>-]/g, ' ')
.replace(/\|/g, ' ')
return withoutMdSyntax.replace(/\s+/g, ' ').trim().length
}
const fallback = (md: string): number => {
const withoutCode = md.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '')
const withoutImagesAndLinks = withoutCode
.replace(/!\[[^\]]*]\([^)]+\)/g, ' ')
.replace(/\[[^\]]*]\([^)]+\)/g, ' ')
const withoutHtml = withoutImagesAndLinks.replace(/<[^>]+>/g, ' ')
const withoutMdSyntax = withoutHtml
.replace(/^>{1}\s?.*$/gm, ' ')
.replace(/^#{1,6}\s+/gm, ' ')
.replace(/[*_~`>-]/g, ' ')
.replace(/\|/g, ' ')
return withoutMdSyntax.replace(/\s+/g, ' ').trim().length
}
if (typeof window === 'undefined' || typeof globalThis.DOMParser === 'undefined') {
console.warn(`[Moderation] SSR: no window/DOMParser, falling back for countText`)
return fallback(markdown)
}
if (typeof window === 'undefined' || typeof globalThis.DOMParser === 'undefined') {
console.warn(`[Moderation] SSR: no window/DOMParser, falling back for countText`)
return fallback(markdown)
}
try {
const htmlString = renderHighlightedString(markdown)
const parser = new DOMParser()
const doc = parser.parseFromString(htmlString, 'text/html')
const walker = doc.createTreeWalker(doc.body || doc, NodeFilter.SHOW_TEXT)
try {
const htmlString = renderHighlightedString(markdown)
const parser = new DOMParser()
const doc = parser.parseFromString(htmlString, 'text/html')
const walker = doc.createTreeWalker(doc.body || doc, NodeFilter.SHOW_TEXT)
const textList: string[] = []
let node = walker.nextNode()
while (node) {
if (node.textContent) textList.push(node.textContent)
node = walker.nextNode()
}
return textList.join(' ').replace(/\s+/g, ' ').trim().length
} catch {
return fallback(markdown)
}
const textList: string[] = []
let node = walker.nextNode()
while (node) {
if (node.textContent) textList.push(node.textContent)
node = walker.nextNode()
}
return textList.join(' ').replace(/\s+/g, ' ').trim().length
} catch {
return fallback(markdown)
}
}
export const descriptionNags: Nag[] = [
{
id: 'description-too-short',
title: defineMessage({
id: 'nags.description-too-short.title',
defaultMessage: 'Expand the description',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const readableLength = countText(context.project.body || '')
{
id: 'description-too-short',
title: defineMessage({
id: 'nags.description-too-short.title',
defaultMessage: 'Expand the description',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const readableLength = countText(context.project.body || '')
return formatMessage(
defineMessage({
id: 'nags.description-too-short.description',
defaultMessage:
'Your description is {length} readable characters. At least {minChars} characters is recommended to create a clear and informative description.',
}),
{
length: readableLength,
minChars: MIN_DESCRIPTION_CHARS,
},
)
},
status: 'warning',
shouldShow: (context: NagContext) => {
const readableLength = countText(context.project.body || '')
return readableLength < MIN_DESCRIPTION_CHARS && readableLength > 0
},
link: {
path: 'settings/description',
title: defineMessage({
id: 'nags.edit-description.title',
defaultMessage: 'Edit description',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
},
},
{
id: 'long-headers',
title: defineMessage({
id: 'nags.long-headers.title',
defaultMessage: 'Shorten headers',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const { longHeaders } = analyzeHeaderLength(context.project.body || '')
const count = longHeaders.length
return formatMessage(
defineMessage({
id: 'nags.description-too-short.description',
defaultMessage:
'Your description is {length} readable characters. At least {minChars} characters is recommended to create a clear and informative description.',
}),
{
length: readableLength,
minChars: MIN_DESCRIPTION_CHARS,
},
)
},
status: 'warning',
shouldShow: (context: NagContext) => {
const readableLength = countText(context.project.body || '')
return readableLength < MIN_DESCRIPTION_CHARS && readableLength > 0
},
link: {
path: 'settings/description',
title: defineMessage({
id: 'nags.edit-description.title',
defaultMessage: 'Edit description',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
},
},
{
id: 'long-headers',
title: defineMessage({
id: 'nags.long-headers.title',
defaultMessage: 'Shorten headers',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const { longHeaders } = analyzeHeaderLength(context.project.body || '')
const count = longHeaders.length
return formatMessage(
defineMessage({
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.',
}),
{
count,
},
)
},
status: 'warning',
shouldShow: (context: NagContext) => {
const { hasLongHeaders } = analyzeHeaderLength(context.project.body || '')
return hasLongHeaders
},
link: {
path: 'settings/description',
title: defineMessage({
id: 'nags.edit-description.title',
defaultMessage: 'Edit description',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
},
},
{
id: 'summary-too-short',
title: defineMessage({
id: 'nags.summary-too-short.title',
defaultMessage: 'Expand the summary',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
return formatMessage(
defineMessage({
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.',
}),
{
count,
},
)
},
status: 'warning',
shouldShow: (context: NagContext) => {
const { hasLongHeaders } = analyzeHeaderLength(context.project.body || '')
return hasLongHeaders
},
link: {
path: 'settings/description',
title: defineMessage({
id: 'nags.edit-description.title',
defaultMessage: 'Edit description',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
},
},
{
id: 'summary-too-short',
title: defineMessage({
id: 'nags.summary-too-short.title',
defaultMessage: 'Expand the summary',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
return formatMessage(
defineMessage({
id: 'nags.summary-too-short.description',
defaultMessage:
'Your summary is {length} characters. At least {minChars} characters is recommended to create an informative and enticing summary.',
}),
{
length: context.project.description?.length || 0,
minChars: MIN_SUMMARY_CHARS,
},
)
},
status: 'warning',
shouldShow: (context: NagContext) => {
const summaryLength = context.project.description?.trim()?.length || 0
return summaryLength < MIN_SUMMARY_CHARS && summaryLength !== 0
},
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-summary.title',
defaultMessage: 'Edit summary',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
id: 'summary-special-formatting',
title: defineMessage({
id: 'nags.summary-special-formatting.title',
defaultMessage: 'Clear up the summary',
}),
description: defineMessage({
id: 'nags.summary-special-formatting.description',
defaultMessage: `Your summary should not contain formatting, line breaks, special characters, or links, since the summary will only display plain text.`,
}),
status: 'warning',
shouldShow: (context: NagContext) => {
const summary = context.project.description?.trim() || ''
return Boolean(
summary.match(/https:\/\//g) ||
summary.match(/http:\/\//g) ||
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) ||
summary.match(/```/g) ||
summary.match(/> /g),
)
},
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-summary.title',
defaultMessage: 'Edit summary',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
id: 'minecraft-title-clause',
title: defineMessage({
id: 'nags.minecraft-title-clause.title',
defaultMessage: 'Avoid brand infringement',
}),
description: defineMessage({
id: 'nags.minecraft-title-clause.description',
defaultMessage: `Projects must not use Minecraft's branding or include "Minecraft" as a significant part of the name.`,
}),
status: 'warning',
shouldShow: (context: NagContext) => {
const title = context.project.title?.toLowerCase() || ''
const wordsInTitle = title.split(' ').filter((word) => word.length > 0)
return title.includes('minecraft') && title.length > 0 && wordsInTitle.length <= 3
},
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-title.title',
defaultMessage: 'Edit title',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
id: 'title-contains-technical-info',
title: defineMessage({
id: 'nags.title-contains-technical-info.title',
defaultMessage: 'Clean up the name',
}),
description: defineMessage({
id: 'nags.title-contains-technical-info.description',
defaultMessage:
"Keeping your project's Name clean and makes it memorable easier to find. Version and loader information is automatically displayed alongside your project.",
}),
status: 'warning',
shouldShow: (context: NagContext) => {
const title = context.project.title?.toLowerCase() || ''
if (!title) return false
return formatMessage(
defineMessage({
id: 'nags.summary-too-short.description',
defaultMessage:
'Your summary is {length} characters. At least {minChars} characters is recommended to create an informative and enticing summary.',
}),
{
length: context.project.description?.length || 0,
minChars: MIN_SUMMARY_CHARS,
},
)
},
status: 'warning',
shouldShow: (context: NagContext) => {
const summaryLength = context.project.description?.trim()?.length || 0
return summaryLength < MIN_SUMMARY_CHARS && summaryLength !== 0
},
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-summary.title',
defaultMessage: 'Edit summary',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
id: 'summary-special-formatting',
title: defineMessage({
id: 'nags.summary-special-formatting.title',
defaultMessage: 'Clear up the summary',
}),
description: defineMessage({
id: 'nags.summary-special-formatting.description',
defaultMessage: `Your summary should not contain formatting, line breaks, special characters, or links, since the summary will only display plain text.`,
}),
status: 'warning',
shouldShow: (context: NagContext) => {
const summary = context.project.description?.trim() || ''
return Boolean(
summary.match(/https:\/\//g) ||
summary.match(/http:\/\//g) ||
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) ||
summary.match(/```/g) ||
summary.match(/> /g),
)
},
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-summary.title',
defaultMessage: 'Edit summary',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
id: 'minecraft-title-clause',
title: defineMessage({
id: 'nags.minecraft-title-clause.title',
defaultMessage: 'Avoid brand infringement',
}),
description: defineMessage({
id: 'nags.minecraft-title-clause.description',
defaultMessage: `Projects must not use Minecraft's branding or include "Minecraft" as a significant part of the name.`,
}),
status: 'warning',
shouldShow: (context: NagContext) => {
const title = context.project.title?.toLowerCase() || ''
const wordsInTitle = title.split(' ').filter((word) => word.length > 0)
return title.includes('minecraft') && title.length > 0 && wordsInTitle.length <= 3
},
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-title.title',
defaultMessage: 'Edit title',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
id: 'title-contains-technical-info',
title: defineMessage({
id: 'nags.title-contains-technical-info.title',
defaultMessage: 'Clean up the name',
}),
description: defineMessage({
id: 'nags.title-contains-technical-info.description',
defaultMessage:
"Keeping your project's Name clean and makes it memorable easier to find. Version and loader information is automatically displayed alongside your project.",
}),
status: 'warning',
shouldShow: (context: NagContext) => {
const title = context.project.title?.toLowerCase() || ''
if (!title) return false
const loaderNames =
context.tags.loaders?.map((loader: { name: string }) => loader.name?.toLowerCase()) || []
const hasLoader = loaderNames.some((loader) => loader && title.includes(loader.toLowerCase()))
const versionPatterns = [/\b1\.\d+(\.\d+)?\b/]
const hasVersionPattern = versionPatterns.some((pattern) => pattern.test(title))
const loaderNames =
context.tags.loaders?.map((loader: { name: string }) => loader.name?.toLowerCase()) || []
const hasLoader = loaderNames.some((loader) => loader && title.includes(loader.toLowerCase()))
const versionPatterns = [/\b1\.\d+(\.\d+)?\b/]
const hasVersionPattern = versionPatterns.some((pattern) => pattern.test(title))
return hasLoader || hasVersionPattern
},
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-title.title',
defaultMessage: 'Edit title',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
id: 'summary-same-as-title',
title: defineMessage({
id: 'nags.summary-same-as-title.title',
defaultMessage: 'Make the summary unique',
}),
description: defineMessage({
id: 'nags.summary-same-as-title.description',
defaultMessage:
"Your summary can not be the same as your project's Name. It's important to create an informative and enticing Summary.",
}),
status: 'required',
shouldShow: (context: NagContext) => {
const title = context.project.title?.trim() || ''
const summary = context.project.description?.trim() || ''
return title === summary && title.length > 0 && summary.length > 0
},
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-summary.title',
defaultMessage: 'Edit summary',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
// Don't like this one, is this needed?
id: 'image-heavy-description',
title: defineMessage({
id: 'nags.image-heavy-description.title',
defaultMessage: 'Ensure accessibility',
}),
description: defineMessage({
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.',
}),
status: 'warning',
shouldShow: (context: NagContext) => {
const { imageHeavy } = analyzeImageContent(context.project.body || '')
return imageHeavy
},
link: {
path: 'settings/description',
title: defineMessage({
id: 'nags.edit-description.title',
defaultMessage: 'Edit description',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
},
},
{
id: 'missing-alt-text',
title: defineMessage({
id: 'nags.missing-alt-text.title',
defaultMessage: 'Add image alt text',
}),
description: defineMessage({
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.',
}),
status: 'warning',
shouldShow: (context: NagContext) => {
const { hasEmptyAltText } = analyzeImageContent(context.project.body || '')
return hasEmptyAltText
},
link: {
path: 'settings/description',
title: defineMessage({
id: 'nags.edit-description.title',
defaultMessage: 'Edit description',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
},
},
return hasLoader || hasVersionPattern
},
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-title.title',
defaultMessage: 'Edit title',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
id: 'summary-same-as-title',
title: defineMessage({
id: 'nags.summary-same-as-title.title',
defaultMessage: 'Make the summary unique',
}),
description: defineMessage({
id: 'nags.summary-same-as-title.description',
defaultMessage:
"Your summary can not be the same as your project's Name. It's important to create an informative and enticing Summary.",
}),
status: 'required',
shouldShow: (context: NagContext) => {
const title = context.project.title?.trim() || ''
const summary = context.project.description?.trim() || ''
return title === summary && title.length > 0 && summary.length > 0
},
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-summary.title',
defaultMessage: 'Edit summary',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
// Don't like this one, is this needed?
id: 'image-heavy-description',
title: defineMessage({
id: 'nags.image-heavy-description.title',
defaultMessage: 'Ensure accessibility',
}),
description: defineMessage({
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.',
}),
status: 'warning',
shouldShow: (context: NagContext) => {
const { imageHeavy } = analyzeImageContent(context.project.body || '')
return imageHeavy
},
link: {
path: 'settings/description',
title: defineMessage({
id: 'nags.edit-description.title',
defaultMessage: 'Edit description',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
},
},
{
id: 'missing-alt-text',
title: defineMessage({
id: 'nags.missing-alt-text.title',
defaultMessage: 'Add image alt text',
}),
description: defineMessage({
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.',
}),
status: 'warning',
shouldShow: (context: NagContext) => {
const { hasEmptyAltText } = analyzeImageContent(context.project.body || '')
return hasEmptyAltText
},
link: {
path: 'settings/description',
title: defineMessage({
id: 'nags.edit-description.title',
defaultMessage: 'Edit description',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
},
},
]

View File

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

View File

@@ -1,281 +1,282 @@
import type { Nag, NagContext } from '../../types/nags'
import { formatProjectType } from '@modrinth/utils'
import { useVIntl, defineMessage } from '@vintl/vintl'
import { defineMessage, useVIntl } from '@vintl/vintl'
import type { Nag, NagContext } from '../../types/nags'
export const commonLinkDomains = {
source: ['github.com', 'gitlab.com', 'bitbucket.org', 'codeberg.org', 'git.sr.ht'],
issues: ['github.com', 'gitlab.com', 'bitbucket.org', 'codeberg.org', 'docs.google.com'],
discord: ['discord.gg', 'discord.com', 'dsc.gg'],
licenseBlocklist: [
'youtube.com',
'youtu.be',
'modrinth.com',
'curseforge.com',
'twitter.com',
'x.com',
'discord.gg',
'discord.com',
'instagram.com',
'facebook.com',
'tiktok.com',
'reddit.com',
'twitch.tv',
'patreon.com',
'ko-fi.com',
'paypal.com',
'buymeacoffee.com',
'google.com',
'example.com',
't.me',
],
linkShorteners: ['bit.ly', 'adf.ly', 'tinyurl.com', 'short.io', 'is.gd'],
source: ['github.com', 'gitlab.com', 'bitbucket.org', 'codeberg.org', 'git.sr.ht'],
issues: ['github.com', 'gitlab.com', 'bitbucket.org', 'codeberg.org', 'docs.google.com'],
discord: ['discord.gg', 'discord.com', 'dsc.gg'],
licenseBlocklist: [
'youtube.com',
'youtu.be',
'modrinth.com',
'curseforge.com',
'twitter.com',
'x.com',
'discord.gg',
'discord.com',
'instagram.com',
'facebook.com',
'tiktok.com',
'reddit.com',
'twitch.tv',
'patreon.com',
'ko-fi.com',
'paypal.com',
'buymeacoffee.com',
'google.com',
'example.com',
't.me',
],
linkShorteners: ['bit.ly', 'adf.ly', 'tinyurl.com', 'short.io', 'is.gd'],
}
export function isCommonUrl(url: string | null, commonDomains: string[]): boolean {
if (url === null || url === '') return true
try {
const domain = new URL(url).hostname.toLowerCase()
return commonDomains.some((allowed) => domain.includes(allowed))
} catch {
return false
}
if (url === null || url === '') return true
try {
const domain = new URL(url).hostname.toLowerCase()
return commonDomains.some((allowed) => domain.includes(allowed))
} catch {
return false
}
}
export function isCommonUrlOfType(url: string | null, commonDomains: string[]): boolean {
if (url === null || url === '') return false
return isCommonUrl(url, commonDomains)
if (url === null || url === '') return false
return isCommonUrl(url, commonDomains)
}
export function isDiscordUrl(url: string | null): boolean {
return isCommonUrlOfType(url, commonLinkDomains.discord)
return isCommonUrlOfType(url, commonLinkDomains.discord)
}
export function isLinkShortener(url: string | null): boolean {
return isCommonUrlOfType(url, commonLinkDomains.linkShorteners)
return isCommonUrlOfType(url, commonLinkDomains.linkShorteners)
}
export function isUncommonLicenseUrl(url: string | null): boolean {
return isCommonUrlOfType(url, commonLinkDomains.licenseBlocklist)
return isCommonUrlOfType(url, commonLinkDomains.licenseBlocklist)
}
export const linksNags: Nag[] = [
{
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) => {
return (
!isCommonUrl(context.project.source_url ?? null, commonLinkDomains.source) ||
!isCommonUrl(context.project.issues_url ?? null, commonLinkDomains.issues) ||
!isCommonUrl(context.project.discord_url ?? null, commonLinkDomains.discord)
)
},
link: {
path: 'settings/links',
title: defineMessage({
id: 'nags.visit-links-settings.title',
defaultMessage: 'Visit links settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-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) =>
isDiscordUrl(context.project.source_url ?? null) ||
isDiscordUrl(context.project.issues_url ?? null) ||
isDiscordUrl(context.project.wiki_url ?? null),
link: {
path: 'settings/links',
title: defineMessage({
id: 'nags.visit-links-settings.title',
defaultMessage: 'Visit links settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-links',
},
},
{
id: 'link-shortener-usage',
title: defineMessage({
id: 'nags.link-shortener-usage.title',
defaultMessage: "Don't use link shorteners",
}),
description: defineMessage({
id: 'nags.link-shortener-usage.description',
defaultMessage:
'Use of link shorteners or other methods to obscure where a link may lead in your external links or license link is prohibited, please only use appropriate full length links.',
}),
status: 'required',
shouldShow: (context: NagContext) => {
if (context.project.donation_urls) {
for (const donation of context.project.donation_urls) {
if (isLinkShortener(donation.url ?? null)) {
return true
}
}
}
{
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) => {
return (
!isCommonUrl(context.project.source_url ?? null, commonLinkDomains.source) ||
!isCommonUrl(context.project.issues_url ?? null, commonLinkDomains.issues) ||
!isCommonUrl(context.project.discord_url ?? null, commonLinkDomains.discord)
)
},
link: {
path: 'settings/links',
title: defineMessage({
id: 'nags.visit-links-settings.title',
defaultMessage: 'Visit links settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-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) =>
isDiscordUrl(context.project.source_url ?? null) ||
isDiscordUrl(context.project.issues_url ?? null) ||
isDiscordUrl(context.project.wiki_url ?? null),
link: {
path: 'settings/links',
title: defineMessage({
id: 'nags.visit-links-settings.title',
defaultMessage: 'Visit links settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-links',
},
},
{
id: 'link-shortener-usage',
title: defineMessage({
id: 'nags.link-shortener-usage.title',
defaultMessage: "Don't use link shorteners",
}),
description: defineMessage({
id: 'nags.link-shortener-usage.description',
defaultMessage:
'Use of link shorteners or other methods to obscure where a link may lead in your external links or license link is prohibited, please only use appropriate full length links.',
}),
status: 'required',
shouldShow: (context: NagContext) => {
if (context.project.donation_urls) {
for (const donation of context.project.donation_urls) {
if (isLinkShortener(donation.url ?? null)) {
return true
}
}
}
return (
isLinkShortener(context.project.source_url ?? null) ||
isLinkShortener(context.project.issues_url ?? null) ||
isLinkShortener(context.project.wiki_url ?? null) ||
isLinkShortener(context.project.discord_url ?? null) ||
Boolean(context.project.license.url && isLinkShortener(context.project.license.url ?? 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
return (
isLinkShortener(context.project.source_url ?? null) ||
isLinkShortener(context.project.issues_url ?? null) ||
isLinkShortener(context.project.wiki_url ?? null) ||
isLinkShortener(context.project.discord_url ?? null) ||
Boolean(context.project.license.url && isLinkShortener(context.project.license.url ?? 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.',
}),
)
}
if (!licenseUrl) {
return formatMessage(
defineMessage({
id: 'nags.invalid-license-url.description.default',
defaultMessage: 'License URL is invalid.',
}),
)
}
try {
const domain = new URL(licenseUrl).hostname.toLowerCase()
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 },
)
} catch {
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
try {
const domain = new URL(licenseUrl).hostname.toLowerCase()
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 },
)
} catch {
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
const isBlocklisted = isUncommonLicenseUrl(licenseUrl)
const isBlocklisted = isUncommonLicenseUrl(licenseUrl)
try {
new URL(licenseUrl)
return isBlocklisted
} catch {
return true
}
},
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-license.title',
defaultMessage: 'Edit license',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-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()
try {
new URL(licenseUrl)
return isBlocklisted
} catch {
return true
}
},
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-license.title',
defaultMessage: 'Edit license',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-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 {projectType} 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.',
}),
{
projectType: formatProjectType(context.project.project_type).toLowerCase(),
},
)
},
status: 'required',
shouldShow: (context: NagContext) => {
const gplLicenses = [
'GPL-2.0',
'GPL-2.0+',
'GPL-2.0-only',
'GPL-2.0-or-later',
'GPL-3.0',
'GPL-3.0+',
'GPL-3.0-only',
'GPL-3.0-or-later',
'LGPL-2.1',
'LGPL-2.1+',
'LGPL-2.1-only',
'LGPL-2.1-or-later',
'LGPL-3.0',
'LGPL-3.0+',
'LGPL-3.0-only',
'LGPL-3.0-or-later',
'AGPL-3.0',
'AGPL-3.0+',
'AGPL-3.0-only',
'AGPL-3.0-or-later',
'MPL-2.0',
]
return formatMessage(
defineMessage({
id: 'nags.gpl-license-source-required.description',
defaultMessage:
'Your {projectType} 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.',
}),
{
projectType: formatProjectType(context.project.project_type).toLowerCase(),
},
)
},
status: 'required',
shouldShow: (context: NagContext) => {
const gplLicenses = [
'GPL-2.0',
'GPL-2.0+',
'GPL-2.0-only',
'GPL-2.0-or-later',
'GPL-3.0',
'GPL-3.0+',
'GPL-3.0-only',
'GPL-3.0-or-later',
'LGPL-2.1',
'LGPL-2.1+',
'LGPL-2.1-only',
'LGPL-2.1-or-later',
'LGPL-3.0',
'LGPL-3.0+',
'LGPL-3.0-only',
'LGPL-3.0-or-later',
'AGPL-3.0',
'AGPL-3.0+',
'AGPL-3.0-only',
'AGPL-3.0-or-later',
'MPL-2.0',
]
const isGplLicense = gplLicenses.includes(context.project.license.id)
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
}
const notSourceAsDistributed = (context: NagContext) =>
context.project.project_type === 'mod' || context.project.project_type === 'plugin'
const isGplLicense = gplLicenses.includes(context.project.license.id)
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
}
const notSourceAsDistributed = (context: NagContext) =>
context.project.project_type === 'mod' || context.project.project_type === 'plugin'
return (
isGplLicense &&
notSourceAsDistributed(context) &&
!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-id-settings-links',
},
},
return (
isGplLicense &&
notSourceAsDistributed(context) &&
!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-id-settings-links',
},
},
]

View File

@@ -1,160 +1,161 @@
import type { Project } from '@modrinth/utils'
import { defineMessage, useVIntl } from '@vintl/vintl'
import type { Nag, NagContext } from '../../types/nags'
import { useVIntl, defineMessage } from '@vintl/vintl'
const allResolutionTags = ['8x-', '16x', '32x', '48x', '64x', '128x', '256x', '512x+']
const MAX_TAG_COUNT = 8
function getCategories(
project: Project & { actualProjectType: string },
tags: {
categories?: {
project_type: string
}[]
},
project: Project & { actualProjectType: string },
tags: {
categories?: {
project_type: string
}[]
},
) {
return (
tags.categories?.filter(
(category: { project_type: string }) => category.project_type === project.actualProjectType,
) ?? []
)
return (
tags.categories?.filter(
(category: { project_type: string }) => category.project_type === project.actualProjectType,
) ?? []
)
}
export const tagsNags: Nag[] = [
{
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
{
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} tags. Consider reducing to {maxTagCount} or fewer to make sure your project appears in relevant search results.",
}),
{
tagCount,
maxTagCount,
},
)
},
status: 'warning',
shouldShow: (context: NagContext) => {
const tagCount =
context.project.categories.length + (context.project.additional_categories?.length || 0)
return tagCount > MAX_TAG_COUNT
},
link: {
path: 'settings/tags',
title: defineMessage({
id: 'nags.edit-tags.title',
defaultMessage: 'Edit tags',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-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))
return formatMessage(
defineMessage({
id: 'nags.too-many-tags.description',
defaultMessage:
"You've selected {tagCount} tags. Consider reducing to {maxTagCount} or fewer to make sure your project appears in relevant search results.",
}),
{
tagCount,
maxTagCount,
},
)
},
status: 'warning',
shouldShow: (context: NagContext) => {
const tagCount =
context.project.categories.length + (context.project.additional_categories?.length || 0)
return tagCount > MAX_TAG_COUNT
},
link: {
path: 'settings/tags',
title: defineMessage({
id: 'nags.edit-tags.title',
defaultMessage: 'Edit tags',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-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)
})
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} resolution tags ({tags}). Resource packs should typically only have one resolution tag that matches their primary resolution.",
}),
{
count: resolutionTags.length,
tags: sortedTags
.join(', ')
.replace('8x-', '8x or lower')
.replace('512x+', '512x or higher'),
},
)
},
status: 'warning',
shouldShow: (context: NagContext) => {
if (context.project.project_type !== 'resourcepack') return false
return formatMessage(
defineMessage({
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.",
}),
{
count: resolutionTags.length,
tags: sortedTags
.join(', ')
.replace('8x-', '8x or lower')
.replace('512x+', '512x or higher'),
},
)
},
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-id-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 Project & { actualProjectType: string },
context.tags,
)
const totalAvailableTags = categoriesForProjectType.length
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-id-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 Project & { actualProjectType: string },
context.tags,
)
const totalAvailableTags = categoriesForProjectType.length
return formatMessage(
defineMessage({
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 are relevant to your project.",
}),
{
totalAvailableTags,
},
)
},
status: 'required',
shouldShow: (context: NagContext) => {
const categoriesForProjectType = getCategories(
context.project as 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-id-settings-tags',
},
},
return formatMessage(
defineMessage({
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 are relevant to your project.",
}),
{
totalAvailableTags,
},
)
},
status: 'required',
shouldShow: (context: NagContext) => {
const categoriesForProjectType = getCategories(
context.project as 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-id-settings-tags',
},
},
]

View File

@@ -1,34 +1,34 @@
import type { ReportQuickReply } from '../types/reports'
export default [
{
label: 'Antivirus',
message: async () => (await import('./messages/reports/antivirus.md?raw')).default,
private: false,
},
{
label: 'Spam',
message: async () => (await import('./messages/reports/spam.md?raw')).default,
private: false,
},
{
label: 'Gameplay Issue',
message: async () => (await import('./messages/reports/gameplay-issue.md?raw')).default,
private: false,
},
{
label: 'Platform Issue',
message: async () => (await import('./messages/reports/platform-issue.md?raw')).default,
private: false,
},
{
label: 'Stale',
message: async () => (await import('./messages/reports/stale.md?raw')).default,
private: false,
},
{
label: 'Confirmed Malware',
message: async () => (await import('./messages/reports/confirmed-malware.md?raw')).default,
private: false,
},
{
label: 'Antivirus',
message: async () => (await import('./messages/reports/antivirus.md?raw')).default,
private: false,
},
{
label: 'Spam',
message: async () => (await import('./messages/reports/spam.md?raw')).default,
private: false,
},
{
label: 'Gameplay Issue',
message: async () => (await import('./messages/reports/gameplay-issue.md?raw')).default,
private: false,
},
{
label: 'Platform Issue',
message: async () => (await import('./messages/reports/platform-issue.md?raw')).default,
private: false,
},
{
label: 'Stale',
message: async () => (await import('./messages/reports/stale.md?raw')).default,
private: false,
},
{
label: 'Confirmed Malware',
message: async () => (await import('./messages/reports/confirmed-malware.md?raw')).default,
private: false,
},
] as ReadonlyArray<ReportQuickReply>

View File

@@ -1,58 +1,59 @@
import type { Stage } from '../../types/stage'
import type { ButtonAction } from '../../types/actions'
import { TagsIcon } from '@modrinth/assets'
import type { ButtonAction } from '../../types/actions'
import type { Stage } from '../../types/stage'
const categories: Stage = {
title: "Are the project's tags accurate?",
id: 'tags',
icon: TagsIcon,
guidance_url: 'https://modrinth.com/legal/rules#miscellaneous',
navigate: '/settings/tags',
shouldShow: (project) =>
project.categories.length > 0 || project.additional_categories.length > 0,
text: async () => {
return (await import('../messages/checklist-text/categories.md?raw')).default
},
actions: [
{
id: 'categories_inaccurate',
type: 'button',
label: 'Inaccurate',
weight: 700,
suggestedStatus: 'flagged',
severity: 'low',
message: async () => (await import('../messages/categories/inaccurate.md?raw')).default,
disablesActions: ['categories_optimization_misused', 'categories_resolutions_misused'],
} as ButtonAction,
{
id: 'categories_optimization_misused',
type: 'button',
label: 'Optimization',
weight: 701,
suggestedStatus: 'flagged',
severity: 'low',
shouldShow: (project) =>
project.categories.includes('optimization') ||
project.additional_categories.includes('optimization'),
message: async () =>
(await import('../messages/categories/inaccurate.md?raw')).default +
(await import('../messages/categories/optimization_misused.md?raw')).default,
disablesActions: ['categories_inaccurate', 'categories_resolutions_misused'],
} as ButtonAction,
{
id: 'categories_resolutions_misused',
type: 'button',
label: 'Resolutions',
weight: 702,
suggestedStatus: 'flagged',
severity: 'low',
shouldShow: (project) => project.project_type === 'resourcepack',
message: async () =>
(await import('../messages/categories/inaccurate.md?raw')).default +
(await import('../messages/categories/resolutions_misused.md?raw')).default,
disablesActions: ['categories_inaccurate', 'categories_optimization_misused'],
},
],
title: "Are the project's tags accurate?",
id: 'tags',
icon: TagsIcon,
guidance_url: 'https://modrinth.com/legal/rules#miscellaneous',
navigate: '/settings/tags',
shouldShow: (project) =>
project.categories.length > 0 || project.additional_categories.length > 0,
text: async () => {
return (await import('../messages/checklist-text/categories.md?raw')).default
},
actions: [
{
id: 'categories_inaccurate',
type: 'button',
label: 'Inaccurate',
weight: 700,
suggestedStatus: 'flagged',
severity: 'low',
message: async () => (await import('../messages/categories/inaccurate.md?raw')).default,
disablesActions: ['categories_optimization_misused', 'categories_resolutions_misused'],
} as ButtonAction,
{
id: 'categories_optimization_misused',
type: 'button',
label: 'Optimization',
weight: 701,
suggestedStatus: 'flagged',
severity: 'low',
shouldShow: (project) =>
project.categories.includes('optimization') ||
project.additional_categories.includes('optimization'),
message: async () =>
(await import('../messages/categories/inaccurate.md?raw')).default +
(await import('../messages/categories/optimization_misused.md?raw')).default,
disablesActions: ['categories_inaccurate', 'categories_resolutions_misused'],
} as ButtonAction,
{
id: 'categories_resolutions_misused',
type: 'button',
label: 'Resolutions',
weight: 702,
suggestedStatus: 'flagged',
severity: 'low',
shouldShow: (project) => project.project_type === 'resourcepack',
message: async () =>
(await import('../messages/categories/inaccurate.md?raw')).default +
(await import('../messages/categories/resolutions_misused.md?raw')).default,
disablesActions: ['categories_inaccurate', 'categories_optimization_misused'],
},
],
}
export default categories

View File

@@ -1,109 +1,110 @@
import type { Stage } from '../../types/stage'
import type { ButtonAction } from '../../types/actions'
import { LibraryIcon } from '@modrinth/assets'
import type { ButtonAction } from '../../types/actions'
import type { Stage } from '../../types/stage'
const description: Stage = {
title: 'Is the description sufficient, accurate, and accessible?',
id: 'description',
icon: LibraryIcon,
guidance_url: 'https://modrinth.com/legal/rules#general-expectations',
navigate: '/',
actions: [
{
id: 'description_insufficient',
type: 'button',
label: 'Insufficient (custom)',
weight: 400,
suggestedStatus: 'flagged',
severity: 'medium',
message: async () => (await import('../messages/description/insufficient.md?raw')).default,
relevantExtraInput: [
{
label: 'Please elaborate on how the author can improve their description.',
variable: 'EXPLAINER',
large: true,
required: true,
},
],
} as ButtonAction,
{
id: 'description_insufficient_packs',
type: 'button',
label: 'Insufficient',
weight: 401,
suggestedStatus: 'flagged',
severity: 'medium',
shouldShow: (project) => project.project_type === 'modpack',
message: async () =>
(await import('../messages/description/insufficient-packs.md?raw')).default,
} as ButtonAction,
{
id: 'description_insufficient_projects',
type: 'button',
label: 'Insufficient',
weight: 401,
suggestedStatus: 'flagged',
severity: 'medium',
shouldShow: (project) => project.project_type !== 'modpack',
message: async () =>
(await import('../messages/description/insufficient-projects.md?raw')).default,
} as ButtonAction,
{
id: 'description_non_english',
type: 'button',
label: 'Non-english',
weight: 402,
suggestedStatus: 'flagged',
severity: 'medium',
message: async () => (await import('../messages/description/non-english.md?raw')).default,
} as ButtonAction,
{
id: 'description_unfinished',
type: 'button',
label: 'Unfinished',
weight: 403,
suggestedStatus: 'flagged',
severity: 'low',
message: async () => (await import('../messages/description/unfinished.md?raw')).default,
} as ButtonAction,
{
id: 'description_headers_as_body',
type: 'button',
label: 'Headers as body text',
weight: 404,
suggestedStatus: 'flagged',
severity: 'low',
message: async () => (await import('../messages/description/headers-as-body.md?raw')).default,
} as ButtonAction,
{
id: 'description_image_only',
type: 'button',
label: 'Image-only',
weight: 405,
suggestedStatus: 'flagged',
severity: 'medium',
message: async () => (await import('../messages/description/image-only.md?raw')).default,
} as ButtonAction,
{
id: 'description_non_standard_text',
type: 'button',
label: 'Non-standard text',
weight: 406,
suggestedStatus: 'flagged',
severity: 'medium',
message: async () =>
(await import('../messages/description/non-standard-text.md?raw')).default,
} as ButtonAction,
{
id: 'description_clarity',
type: 'button',
label: 'Unclear / Misleading',
weight: 407,
suggestedStatus: 'rejected',
severity: 'high',
message: async () => (await import('../messages/description/clarity.md?raw')).default,
} as ButtonAction,
],
title: 'Is the description sufficient, accurate, and accessible?',
id: 'description',
icon: LibraryIcon,
guidance_url: 'https://modrinth.com/legal/rules#general-expectations',
navigate: '/',
actions: [
{
id: 'description_insufficient',
type: 'button',
label: 'Insufficient (custom)',
weight: 400,
suggestedStatus: 'flagged',
severity: 'medium',
message: async () => (await import('../messages/description/insufficient.md?raw')).default,
relevantExtraInput: [
{
label: 'Please elaborate on how the author can improve their description.',
variable: 'EXPLAINER',
large: true,
required: true,
},
],
} as ButtonAction,
{
id: 'description_insufficient_packs',
type: 'button',
label: 'Insufficient',
weight: 401,
suggestedStatus: 'flagged',
severity: 'medium',
shouldShow: (project) => project.project_type === 'modpack',
message: async () =>
(await import('../messages/description/insufficient-packs.md?raw')).default,
} as ButtonAction,
{
id: 'description_insufficient_projects',
type: 'button',
label: 'Insufficient',
weight: 401,
suggestedStatus: 'flagged',
severity: 'medium',
shouldShow: (project) => project.project_type !== 'modpack',
message: async () =>
(await import('../messages/description/insufficient-projects.md?raw')).default,
} as ButtonAction,
{
id: 'description_non_english',
type: 'button',
label: 'Non-english',
weight: 402,
suggestedStatus: 'flagged',
severity: 'medium',
message: async () => (await import('../messages/description/non-english.md?raw')).default,
} as ButtonAction,
{
id: 'description_unfinished',
type: 'button',
label: 'Unfinished',
weight: 403,
suggestedStatus: 'flagged',
severity: 'low',
message: async () => (await import('../messages/description/unfinished.md?raw')).default,
} as ButtonAction,
{
id: 'description_headers_as_body',
type: 'button',
label: 'Headers as body text',
weight: 404,
suggestedStatus: 'flagged',
severity: 'low',
message: async () => (await import('../messages/description/headers-as-body.md?raw')).default,
} as ButtonAction,
{
id: 'description_image_only',
type: 'button',
label: 'Image-only',
weight: 405,
suggestedStatus: 'flagged',
severity: 'medium',
message: async () => (await import('../messages/description/image-only.md?raw')).default,
} as ButtonAction,
{
id: 'description_non_standard_text',
type: 'button',
label: 'Non-standard text',
weight: 406,
suggestedStatus: 'flagged',
severity: 'medium',
message: async () =>
(await import('../messages/description/non-standard-text.md?raw')).default,
} as ButtonAction,
{
id: 'description_clarity',
type: 'button',
label: 'Unclear / Misleading',
weight: 407,
suggestedStatus: 'rejected',
severity: 'high',
message: async () => (await import('../messages/description/clarity.md?raw')).default,
} as ButtonAction,
],
}
export default description

View File

@@ -1,34 +1,35 @@
import type { Stage } from '../../types/stage'
import type { ButtonAction } from '../../types/actions'
import { ImageIcon } from '@modrinth/assets'
import type { ButtonAction } from '../../types/actions'
import type { Stage } from '../../types/stage'
const gallery: Stage = {
title: "Are this project's gallery images sufficient?",
id: 'gallery',
icon: ImageIcon,
guidance_url: 'https://modrinth.com/legal/rules#general-expectations',
navigate: '/gallery',
actions: [
{
id: 'gallery_insufficient',
type: 'button',
label: 'Insufficient',
weight: 900,
suggestedStatus: 'flagged',
severity: 'low',
message: async () => (await import('../messages/gallery/insufficient.md?raw')).default,
} as ButtonAction,
{
id: 'gallery_not_relevant',
type: 'button',
label: 'Not relevant',
weight: 901,
suggestedStatus: 'flagged',
severity: 'low',
shouldShow: (project) => project.gallery && project.gallery.length > 0,
message: async () => (await import('../messages/gallery/not-relevant.md?raw')).default,
} as ButtonAction,
],
title: "Are this project's gallery images sufficient?",
id: 'gallery',
icon: ImageIcon,
guidance_url: 'https://modrinth.com/legal/rules#general-expectations',
navigate: '/gallery',
actions: [
{
id: 'gallery_insufficient',
type: 'button',
label: 'Insufficient',
weight: 900,
suggestedStatus: 'flagged',
severity: 'low',
message: async () => (await import('../messages/gallery/insufficient.md?raw')).default,
} as ButtonAction,
{
id: 'gallery_not_relevant',
type: 'button',
label: 'Not relevant',
weight: 901,
suggestedStatus: 'flagged',
severity: 'low',
shouldShow: (project) => project.gallery && project.gallery.length > 0,
message: async () => (await import('../messages/gallery/not-relevant.md?raw')).default,
} as ButtonAction,
],
}
export default gallery

View File

@@ -1,84 +1,85 @@
import { BookTextIcon } from '@modrinth/assets'
import type { Stage } from '../../types/stage'
const licensesNotRequiringSource: string[] = [
'LicenseRef-All-Rights-Reserved',
'Apache-2.0',
'BSD-2-Clause',
'BSD-3-Clause',
'CC0-1.0',
'CC-BY-4.0',
'CC-BY-SA-4.0',
'CC-BY-NC-4.0',
'CC-BY-NC-SA-4.0',
'CC-BY-ND-4.0',
'CC-BY-NC-ND-4.0',
'ISC',
'MIT',
'Zlib',
'LicenseRef-All-Rights-Reserved',
'Apache-2.0',
'BSD-2-Clause',
'BSD-3-Clause',
'CC0-1.0',
'CC-BY-4.0',
'CC-BY-SA-4.0',
'CC-BY-NC-4.0',
'CC-BY-NC-SA-4.0',
'CC-BY-ND-4.0',
'CC-BY-NC-ND-4.0',
'ISC',
'MIT',
'Zlib',
]
const licenseStage: Stage = {
title: 'Is this license and link valid?',
text: async () => (await import('../messages/checklist-text/licensing.md?raw')).default,
id: 'license',
icon: BookTextIcon,
guidance_url: 'https://modrinth.com/legal/rules#miscellaneous',
navigate: '/settings/license',
actions: [
{
id: 'license_invalid_link',
type: 'button',
label: 'Invalid Link',
weight: 600,
suggestedStatus: 'flagged',
severity: 'medium',
shouldShow: (project) => Boolean(project.license.url),
message: async () => (await import('../messages/license/invalid_link.md?raw')).default,
enablesActions: [
{
id: 'license_invalid_link-custom_license',
type: 'toggle',
label: 'Invalid Link: Custom License',
weight: 601,
suggestedStatus: 'flagged',
severity: 'medium',
message: async () =>
(await import('../messages/license/invalid_link-custom_license.md?raw')).default,
},
],
},
{
id: 'license_no_source',
type: 'conditional-button',
label: 'No Source',
suggestedStatus: 'rejected',
severity: 'medium',
shouldShow: (project) => !licensesNotRequiringSource.includes(project.license.id),
messageVariants: [
{
conditions: {
excludedActions: ['license_no_source-fork'],
},
weight: 602,
message: async () => (await import('../messages/license/no_source.md?raw')).default,
},
],
fallbackWeight: 602,
fallbackMessage: async () => '',
enablesActions: [
{
id: 'license_no_source-fork',
type: 'toggle',
label: 'No Source: Fork',
weight: 602,
suggestedStatus: 'rejected',
severity: 'high',
message: async () => (await import('../messages/license/no_source-fork.md?raw')).default,
},
],
},
],
title: 'Is this license and link valid?',
text: async () => (await import('../messages/checklist-text/licensing.md?raw')).default,
id: 'license',
icon: BookTextIcon,
guidance_url: 'https://modrinth.com/legal/rules#miscellaneous',
navigate: '/settings/license',
actions: [
{
id: 'license_invalid_link',
type: 'button',
label: 'Invalid Link',
weight: 600,
suggestedStatus: 'flagged',
severity: 'medium',
shouldShow: (project) => Boolean(project.license.url),
message: async () => (await import('../messages/license/invalid_link.md?raw')).default,
enablesActions: [
{
id: 'license_invalid_link-custom_license',
type: 'toggle',
label: 'Invalid Link: Custom License',
weight: 601,
suggestedStatus: 'flagged',
severity: 'medium',
message: async () =>
(await import('../messages/license/invalid_link-custom_license.md?raw')).default,
},
],
},
{
id: 'license_no_source',
type: 'conditional-button',
label: 'No Source',
suggestedStatus: 'rejected',
severity: 'medium',
shouldShow: (project) => !licensesNotRequiringSource.includes(project.license.id),
messageVariants: [
{
conditions: {
excludedActions: ['license_no_source-fork'],
},
weight: 602,
message: async () => (await import('../messages/license/no_source.md?raw')).default,
},
],
fallbackWeight: 602,
fallbackMessage: async () => '',
enablesActions: [
{
id: 'license_no_source-fork',
type: 'toggle',
label: 'No Source: Fork',
weight: 602,
suggestedStatus: 'rejected',
severity: 'high',
message: async () => (await import('../messages/license/no_source-fork.md?raw')).default,
},
],
},
],
}
export default licenseStage

View File

@@ -1,88 +1,89 @@
import type { Stage } from '../../types/stage'
import type { ButtonAction } from '../../types/actions'
import { LinkIcon } from '@modrinth/assets'
import type { ButtonAction } from '../../types/actions'
import type { Stage } from '../../types/stage'
const links: Stage = {
title: "Are the project's links accurate and accessible?",
id: 'links',
icon: LinkIcon,
guidance_url: 'https://modrinth.com/legal/rules',
navigate: '/settings/links',
shouldShow: (project) =>
Boolean(
project.issues_url ||
project.source_url ||
project.wiki_url ||
project.discord_url ||
project.donation_urls.length > 0,
),
text: async (project) => {
let text = (await import('../messages/checklist-text/links/base.md?raw')).default
title: "Are the project's links accurate and accessible?",
id: 'links',
icon: LinkIcon,
guidance_url: 'https://modrinth.com/legal/rules',
navigate: '/settings/links',
shouldShow: (project) =>
Boolean(
project.issues_url ||
project.source_url ||
project.wiki_url ||
project.discord_url ||
project.donation_urls.length > 0,
),
text: async (project) => {
let text = (await import('../messages/checklist-text/links/base.md?raw')).default
if (project.donation_urls.length > 0) {
text += (await import('../messages/checklist-text/links/donation/donations.md?raw')).default
if (project.donation_urls.length > 0) {
text += (await import('../messages/checklist-text/links/donation/donations.md?raw')).default
for (const donation of project.donation_urls) {
text += (await import(`../messages/checklist-text/links/donation/donation.md?raw`)).default
.replace('{URL}', donation.url)
.replace('{PLATFORM}', donation.platform)
}
}
for (const donation of project.donation_urls) {
text += (await import(`../messages/checklist-text/links/donation/donation.md?raw`)).default
.replace('{URL}', donation.url)
.replace('{PLATFORM}', donation.platform)
}
}
return text
},
actions: [
{
id: 'links_misused',
type: 'button',
label: 'Links are misused',
weight: 500,
suggestedStatus: 'flagged',
severity: 'low',
message: async () => (await import('../messages/links/misused.md?raw')).default,
relevantExtraInput: [
{
label: 'What links are misused?',
variable: 'MISUSED_LINKS',
required: false,
},
],
} as ButtonAction,
{
id: 'links_unaccessible',
type: 'button',
label: 'Links are inaccessible',
weight: 510,
suggestedStatus: 'flagged',
// Theoretically a conditional could go here to prevent overlap of misuse and unaccessible messages repeating while still allowing for a multi-select in each.
// if links_misused was selected, send nothing.
message: async () => (await import('../messages/links/not_accessible.md?raw')).default,
enablesActions: [
{
id: 'links_unaccessible_options',
type: 'multi-select-chips',
label: 'Warn of inaccessible link?',
shouldShow: (project) => Boolean(project.source_url || project.discord_url),
options: [
{
label: 'Source',
weight: 511,
shouldShow: (project) => Boolean(project.source_url),
message: async () =>
(await import('../messages/links/not_accessible-source.md?raw')).default,
},
{
label: 'Discord',
weight: 512,
shouldShow: (project) => Boolean(project.discord_url),
message: async () =>
(await import('../messages/links/not_accessible-discord.md?raw')).default,
},
],
},
],
} as ButtonAction,
],
return text
},
actions: [
{
id: 'links_misused',
type: 'button',
label: 'Links are misused',
weight: 500,
suggestedStatus: 'flagged',
severity: 'low',
message: async () => (await import('../messages/links/misused.md?raw')).default,
relevantExtraInput: [
{
label: 'What links are misused?',
variable: 'MISUSED_LINKS',
required: false,
},
],
} as ButtonAction,
{
id: 'links_unaccessible',
type: 'button',
label: 'Links are inaccessible',
weight: 510,
suggestedStatus: 'flagged',
// Theoretically a conditional could go here to prevent overlap of misuse and unaccessible messages repeating while still allowing for a multi-select in each.
// if links_misused was selected, send nothing.
message: async () => (await import('../messages/links/not_accessible.md?raw')).default,
enablesActions: [
{
id: 'links_unaccessible_options',
type: 'multi-select-chips',
label: 'Warn of inaccessible link?',
shouldShow: (project) => Boolean(project.source_url || project.discord_url),
options: [
{
label: 'Source',
weight: 511,
shouldShow: (project) => Boolean(project.source_url),
message: async () =>
(await import('../messages/links/not_accessible-source.md?raw')).default,
},
{
label: 'Discord',
weight: 512,
shouldShow: (project) => Boolean(project.discord_url),
message: async () =>
(await import('../messages/links/not_accessible-discord.md?raw')).default,
},
],
},
],
} as ButtonAction,
],
}
export default links

View File

@@ -1,111 +1,112 @@
import type { Stage } from '../../types/stage'
import type { ButtonAction } from '../../types/actions'
import { CopyrightIcon } from '@modrinth/assets'
import type { ButtonAction } from '../../types/actions'
import type { Stage } from '../../types/stage'
const reupload: Stage = {
title: 'Does the author have proper permissions to post this project?',
id: 'reupload',
icon: CopyrightIcon,
guidance_url: 'https://modrinth.com/legal/rules',
actions: [
{
id: 'reupload_reupload',
type: 'button',
label: 'Re-upload',
weight: 1100,
suggestedStatus: 'rejected',
severity: 'high',
message: async () => (await import('../messages/reupload/reupload.md?raw')).default,
disablesActions: [
'reupload_unclear_fork',
'reupload_insufficient_fork',
'reupload_request_proof',
'reupload_identity_verification',
],
relevantExtraInput: [
{
label: 'What is the title of the original project?',
variable: 'ORIGINAL_PROJECT',
required: true,
suggestions: ['Vanilla Tweaks'],
},
{
label: 'What is the author of the original project?',
variable: 'ORIGINAL_AUTHOR',
required: true,
suggestions: ['Vanilla Tweaks Team'],
},
],
} as ButtonAction,
{
id: 'reupload_unclear_fork',
type: 'button',
label: 'Unclear Fork',
weight: 1100,
suggestedStatus: 'rejected',
severity: 'high',
message: async () => (await import('../messages/reupload/fork.md?raw')).default,
disablesActions: [
'reupload_reupload',
'reupload_insufficient_fork',
'reupload_request_proof',
'reupload_identity_verification',
],
} as ButtonAction,
{
id: 'reupload_insufficient_fork',
type: 'button',
label: 'Insufficient Fork',
weight: 1100,
suggestedStatus: 'rejected',
severity: 'high',
message: async () => (await import('../messages/reupload/insufficient_fork.md?raw')).default,
disablesActions: [
'reupload_unclear_fork',
'reupload_reupload',
'reupload_request_proof',
'reupload_identity_verification',
],
} as ButtonAction,
{
id: 'reupload_request_proof',
type: 'button',
label: 'Proof of permissions',
weight: 1100,
suggestedStatus: 'rejected',
severity: 'high',
message: async () =>
(await import('../messages/reupload/proof_of_permissions.md?raw')).default,
disablesActions: [
'reupload_reupload',
'reupload_unclear_fork',
'reupload_insufficient_fork',
'reupload_identity_verification',
],
},
{
id: 'reupload_identity_verification',
type: 'button',
label: 'Verify Identity',
weight: 1100,
suggestedStatus: 'rejected',
severity: 'high',
message: async () =>
(await import('../messages/reupload/identity_verification.md?raw')).default,
relevantExtraInput: [
{
label: 'Where else can the project be found?',
variable: 'PLATFORM',
required: true,
},
],
disablesActions: [
'reupload_reupload',
'reupload_insufficient_fork',
'reupload_request_proof',
],
},
],
title: 'Does the author have proper permissions to post this project?',
id: 'reupload',
icon: CopyrightIcon,
guidance_url: 'https://modrinth.com/legal/rules',
actions: [
{
id: 'reupload_reupload',
type: 'button',
label: 'Re-upload',
weight: 1100,
suggestedStatus: 'rejected',
severity: 'high',
message: async () => (await import('../messages/reupload/reupload.md?raw')).default,
disablesActions: [
'reupload_unclear_fork',
'reupload_insufficient_fork',
'reupload_request_proof',
'reupload_identity_verification',
],
relevantExtraInput: [
{
label: 'What is the title of the original project?',
variable: 'ORIGINAL_PROJECT',
required: true,
suggestions: ['Vanilla Tweaks'],
},
{
label: 'What is the author of the original project?',
variable: 'ORIGINAL_AUTHOR',
required: true,
suggestions: ['Vanilla Tweaks Team'],
},
],
} as ButtonAction,
{
id: 'reupload_unclear_fork',
type: 'button',
label: 'Unclear Fork',
weight: 1100,
suggestedStatus: 'rejected',
severity: 'high',
message: async () => (await import('../messages/reupload/fork.md?raw')).default,
disablesActions: [
'reupload_reupload',
'reupload_insufficient_fork',
'reupload_request_proof',
'reupload_identity_verification',
],
} as ButtonAction,
{
id: 'reupload_insufficient_fork',
type: 'button',
label: 'Insufficient Fork',
weight: 1100,
suggestedStatus: 'rejected',
severity: 'high',
message: async () => (await import('../messages/reupload/insufficient_fork.md?raw')).default,
disablesActions: [
'reupload_unclear_fork',
'reupload_reupload',
'reupload_request_proof',
'reupload_identity_verification',
],
} as ButtonAction,
{
id: 'reupload_request_proof',
type: 'button',
label: 'Proof of permissions',
weight: 1100,
suggestedStatus: 'rejected',
severity: 'high',
message: async () =>
(await import('../messages/reupload/proof_of_permissions.md?raw')).default,
disablesActions: [
'reupload_reupload',
'reupload_unclear_fork',
'reupload_insufficient_fork',
'reupload_identity_verification',
],
},
{
id: 'reupload_identity_verification',
type: 'button',
label: 'Verify Identity',
weight: 1100,
suggestedStatus: 'rejected',
severity: 'high',
message: async () =>
(await import('../messages/reupload/identity_verification.md?raw')).default,
relevantExtraInput: [
{
label: 'Where else can the project be found?',
variable: 'PLATFORM',
required: true,
},
],
disablesActions: [
'reupload_reupload',
'reupload_insufficient_fork',
'reupload_request_proof',
],
},
],
}
export default reupload

View File

@@ -1,33 +1,34 @@
import type { Stage } from '../../types/stage'
import type { ButtonAction } from '../../types/actions'
import { ListBulletedIcon } from '@modrinth/assets'
import type { ButtonAction } from '../../types/actions'
import type { Stage } from '../../types/stage'
const ruleFollowing: Stage = {
title: 'Does this project violate the rules?',
id: 'rule-following',
icon: ListBulletedIcon,
guidance_url:
'https://www.notion.so/Creator-Communication-Guide-1b65ee711bf080ec9337e3ccdded146c',
navigate: '/moderation',
actions: [
{
id: 'rule_breaking_yes',
type: 'button',
label: 'Yes',
weight: 0,
suggestedStatus: 'rejected',
severity: 'critical',
message: async () => (await import('../messages/rule-breaking.md?raw')).default,
relevantExtraInput: [
{
label: 'Please explain to the user how it infringes on our content rules.',
variable: 'MESSAGE',
required: true,
large: true,
},
],
} as ButtonAction,
],
title: 'Does this project violate the rules?',
id: 'rule-following',
icon: ListBulletedIcon,
guidance_url:
'https://www.notion.so/Creator-Communication-Guide-1b65ee711bf080ec9337e3ccdded146c',
navigate: '/moderation',
actions: [
{
id: 'rule_breaking_yes',
type: 'button',
label: 'Yes',
weight: 0,
suggestedStatus: 'rejected',
severity: 'critical',
message: async () => (await import('../messages/rule-breaking.md?raw')).default,
relevantExtraInput: [
{
label: 'Please explain to the user how it infringes on our content rules.',
variable: 'MESSAGE',
required: true,
large: true,
},
],
} as ButtonAction,
],
}
export default ruleFollowing

View File

@@ -1,37 +1,38 @@
import type { Stage } from '../../types/stage'
import type { ButtonAction } from '../../types/actions'
import { GlobeIcon } from '@modrinth/assets'
import type { ButtonAction } from '../../types/actions'
import type { Stage } from '../../types/stage'
const sideTypes: Stage = {
title: "Is the project's environment information accurate?",
id: 'environment',
icon: GlobeIcon,
guidance_url: 'https://modrinth.com/legal/rules#miscellaneous',
navigate: '/settings',
text: async () => (await import('../messages/checklist-text/side_types.md?raw')).default,
actions: [
{
id: 'side_types_inaccurate_modpack',
type: 'button',
label: 'Inaccurate',
weight: 800,
suggestedStatus: 'flagged',
severity: 'low',
shouldShow: (project) => project.project_type === 'modpack',
message: async () =>
(await import('../messages/side-types/inaccurate-modpack.md?raw')).default,
} as ButtonAction,
{
id: 'side_types_inaccurate_mod',
type: 'button',
label: 'Inaccurate',
weight: 800,
suggestedStatus: 'flagged',
severity: 'low',
shouldShow: (project) => project.project_type === 'mod',
message: async () => (await import('../messages/side-types/inaccurate-mod.md?raw')).default,
} as ButtonAction,
],
title: "Is the project's environment information accurate?",
id: 'environment',
icon: GlobeIcon,
guidance_url: 'https://modrinth.com/legal/rules#miscellaneous',
navigate: '/settings',
text: async () => (await import('../messages/checklist-text/side_types.md?raw')).default,
actions: [
{
id: 'side_types_inaccurate_modpack',
type: 'button',
label: 'Inaccurate',
weight: 800,
suggestedStatus: 'flagged',
severity: 'low',
shouldShow: (project) => project.project_type === 'modpack',
message: async () =>
(await import('../messages/side-types/inaccurate-modpack.md?raw')).default,
} as ButtonAction,
{
id: 'side_types_inaccurate_mod',
type: 'button',
label: 'Inaccurate',
weight: 800,
suggestedStatus: 'flagged',
severity: 'low',
shouldShow: (project) => project.project_type === 'mod',
message: async () => (await import('../messages/side-types/inaccurate-mod.md?raw')).default,
} as ButtonAction,
],
}
export default sideTypes

View File

@@ -1,88 +1,89 @@
import type { Stage } from '../../types/stage'
import type { ButtonAction, DropdownAction, DropdownActionOption } from '../../types/actions'
import { TriangleAlertIcon } from '@modrinth/assets'
import type { ButtonAction, DropdownAction, DropdownActionOption } from '../../types/actions'
import type { Stage } from '../../types/stage'
const statusAlerts: Stage = {
title: `Is anything else affecting this project's status?`,
id: 'status-alerts',
icon: TriangleAlertIcon,
text: async () => (await import('../messages/checklist-text/status-alerts/text.md?raw')).default,
guidance_url:
'https://www.notion.so/Project-Modification-Guidelines-22e5ee711bf080628416f0471ba6af02',
navigate: '/moderation',
actions: [
{
id: 'status_corrections_applied',
type: 'button',
label: 'Corrections applied',
weight: -999999,
suggestedStatus: 'approved',
disablesActions: ['status_private_use', 'status_account_issues'],
message: async () => (await import('../messages/status-alerts/fixed.md?raw')).default,
} as ButtonAction,
{
id: 'status_private_use',
type: 'button',
label: 'Private use',
weight: -999999,
suggestedStatus: 'flagged',
disablesActions: ['status_corrections_applied', 'status_account_issues'],
message: async () => (await import('../messages/status-alerts/private.md?raw')).default,
} as ButtonAction,
{
id: 'status_account_issues',
type: 'button',
label: 'Account issues',
weight: -999999,
suggestedStatus: 'rejected',
disablesActions: ['status_corrections_applied', 'status_private_use'],
message: async () =>
(await import('../messages/status-alerts/account_issues.md?raw')).default,
} as ButtonAction,
{
id: 'status_tec_source_request',
type: 'button',
label: `Request Source`,
suggestedStatus: 'rejected',
severity: 'critical',
disablesActions: ['status_corrections_applied', 'status_private_use'],
shouldShow: (project) =>
project.project_type === 'mod' ||
project.project_type === 'shader' ||
project.project_type.toString() === 'plugin',
weight: -999999,
message: async () => '',
enablesActions: [
{
id: 'status_tec_source_request_options',
type: 'dropdown',
label: 'Why are you requesting source?',
options: [
{
label: 'Obfuscated',
weight: 999999,
message: async () =>
(await import('../messages/status-alerts/tec/source_request-obfs.md?raw')).default,
} as DropdownActionOption,
{
label: 'Binaries',
weight: 999000,
message: async () =>
(await import('../messages/status-alerts/tec/source_request-bins.md?raw')).default,
} as DropdownActionOption,
],
} as DropdownAction,
],
} as ButtonAction,
{
id: 'status_automod_confusion',
type: 'button',
label: `Automod confusion`,
weight: -999999,
message: async () =>
(await import('../messages/status-alerts/automod_confusion.md?raw')).default,
} as ButtonAction,
],
title: `Is anything else affecting this project's status?`,
id: 'status-alerts',
icon: TriangleAlertIcon,
text: async () => (await import('../messages/checklist-text/status-alerts/text.md?raw')).default,
guidance_url:
'https://www.notion.so/Project-Modification-Guidelines-22e5ee711bf080628416f0471ba6af02',
navigate: '/moderation',
actions: [
{
id: 'status_corrections_applied',
type: 'button',
label: 'Corrections applied',
weight: -999999,
suggestedStatus: 'approved',
disablesActions: ['status_private_use', 'status_account_issues'],
message: async () => (await import('../messages/status-alerts/fixed.md?raw')).default,
} as ButtonAction,
{
id: 'status_private_use',
type: 'button',
label: 'Private use',
weight: -999999,
suggestedStatus: 'flagged',
disablesActions: ['status_corrections_applied', 'status_account_issues'],
message: async () => (await import('../messages/status-alerts/private.md?raw')).default,
} as ButtonAction,
{
id: 'status_account_issues',
type: 'button',
label: 'Account issues',
weight: -999999,
suggestedStatus: 'rejected',
disablesActions: ['status_corrections_applied', 'status_private_use'],
message: async () =>
(await import('../messages/status-alerts/account_issues.md?raw')).default,
} as ButtonAction,
{
id: 'status_tec_source_request',
type: 'button',
label: `Request Source`,
suggestedStatus: 'rejected',
severity: 'critical',
disablesActions: ['status_corrections_applied', 'status_private_use'],
shouldShow: (project) =>
project.project_type === 'mod' ||
project.project_type === 'shader' ||
project.project_type.toString() === 'plugin',
weight: -999999,
message: async () => '',
enablesActions: [
{
id: 'status_tec_source_request_options',
type: 'dropdown',
label: 'Why are you requesting source?',
options: [
{
label: 'Obfuscated',
weight: 999999,
message: async () =>
(await import('../messages/status-alerts/tec/source_request-obfs.md?raw')).default,
} as DropdownActionOption,
{
label: 'Binaries',
weight: 999000,
message: async () =>
(await import('../messages/status-alerts/tec/source_request-bins.md?raw')).default,
} as DropdownActionOption,
],
} as DropdownAction,
],
} as ButtonAction,
{
id: 'status_automod_confusion',
type: 'button',
label: `Automod confusion`,
weight: -999999,
message: async () =>
(await import('../messages/status-alerts/automod_confusion.md?raw')).default,
} as ButtonAction,
],
}
export default statusAlerts

View File

@@ -1,53 +1,54 @@
import type { Stage } from '../../types/stage'
import type { ButtonAction } from '../../types/actions'
import { AlignLeftIcon } from '@modrinth/assets'
import type { ButtonAction } from '../../types/actions'
import type { Stage } from '../../types/stage'
const summary: Stage = {
title: "Is the project's summary sufficient?",
text: async () => (await import('../messages/checklist-text/summary/summary.md?raw')).default,
id: 'summary',
icon: AlignLeftIcon,
guidance_url: 'https://modrinth.com/legal/rules#miscellaneous',
actions: [
{
id: 'summary_insufficient',
type: 'button',
label: 'Insufficient',
weight: 300,
suggestedStatus: 'flagged',
severity: 'low',
disablesActions: ['summary_repeat_title'],
message: async () => (await import('../messages/summary/insufficient.md?raw')).default,
} as ButtonAction,
{
id: 'summary_repeat_title',
type: 'button',
label: 'Repeat of title',
weight: 300,
suggestedStatus: 'flagged',
severity: 'low',
disablesActions: ['summary_insufficient'],
message: async () => (await import('../messages/summary/repeat-title.md?raw')).default,
} as ButtonAction,
{
id: 'summary_formatting',
type: 'button',
label: 'Formatting',
weight: 301,
suggestedStatus: 'flagged',
severity: 'low',
message: async () => (await import('../messages/summary/formatting.md?raw')).default,
} as ButtonAction,
{
id: 'summary_non_english',
type: 'button',
label: 'Non-english',
weight: 302,
suggestedStatus: 'flagged',
severity: 'medium',
message: async () => (await import('../messages/summary/non-english.md?raw')).default,
} as ButtonAction,
],
title: "Is the project's summary sufficient?",
text: async () => (await import('../messages/checklist-text/summary/summary.md?raw')).default,
id: 'summary',
icon: AlignLeftIcon,
guidance_url: 'https://modrinth.com/legal/rules#miscellaneous',
actions: [
{
id: 'summary_insufficient',
type: 'button',
label: 'Insufficient',
weight: 300,
suggestedStatus: 'flagged',
severity: 'low',
disablesActions: ['summary_repeat_title'],
message: async () => (await import('../messages/summary/insufficient.md?raw')).default,
} as ButtonAction,
{
id: 'summary_repeat_title',
type: 'button',
label: 'Repeat of title',
weight: 300,
suggestedStatus: 'flagged',
severity: 'low',
disablesActions: ['summary_insufficient'],
message: async () => (await import('../messages/summary/repeat-title.md?raw')).default,
} as ButtonAction,
{
id: 'summary_formatting',
type: 'button',
label: 'Formatting',
weight: 301,
suggestedStatus: 'flagged',
severity: 'low',
message: async () => (await import('../messages/summary/formatting.md?raw')).default,
} as ButtonAction,
{
id: 'summary_non_english',
type: 'button',
label: 'Non-english',
weight: 302,
suggestedStatus: 'flagged',
severity: 'medium',
message: async () => (await import('../messages/summary/non-english.md?raw')).default,
} as ButtonAction,
],
}
export default summary

View File

@@ -1,96 +1,97 @@
import { BookOpenIcon } from '@modrinth/assets'
import type { Stage } from '../../types/stage'
import type { Project } from '@modrinth/utils'
import type { Stage } from '../../types/stage'
function hasCustomSlug(project: Project): boolean {
return (
project.slug !==
project.title
.trim()
.toLowerCase()
.replaceAll(' ', '-')
.replaceAll(/[^a-zA-Z0-9!@$()`.+,_"-]/g, '')
.replaceAll(/--+/gm, '-')
)
return (
project.slug !==
project.title
.trim()
.toLowerCase()
.replaceAll(' ', '-')
.replaceAll(/[^a-zA-Z0-9!@$()`.+,_"-]/g, '')
.replaceAll(/--+/gm, '-')
)
}
const titleSlug: Stage = {
title: 'Are the Name and URL accurate and appropriate?',
id: 'title-&-slug',
text: async (project) => {
let text = (await import('../messages/checklist-text/title-slug/title.md?raw')).default
if (hasCustomSlug(project))
text += (await import('../messages/checklist-text/title-slug/slug.md?raw')).default
return text
},
icon: BookOpenIcon,
guidance_url: 'https://modrinth.com/legal/rules#miscellaneous',
actions: [
{
id: 'title_useless_info',
type: 'button',
label: 'Contains useless info',
weight: 100,
suggestedStatus: 'flagged',
severity: 'low',
message: async () => (await import('../messages/title/useless-info.md?raw')).default,
},
{
id: 'title_minecraft_branding',
type: 'button',
label: 'Minecraft title',
weight: 100,
suggestedStatus: 'flagged',
severity: 'medium',
message: async () => (await import('../messages/title/minecraft-branding.md?raw')).default,
},
{
id: 'title_similarities',
type: 'button',
label: 'Title similarities',
weight: 110,
suggestedStatus: 'flagged',
severity: 'medium',
message: async () => (await import('../messages/title/similarities.md?raw')).default,
enablesActions: [
{
id: 'title_similarities_options',
type: 'multi-select-chips',
label: 'Similarities additional info',
options: [
{
label: 'Modpack named after mod',
weight: 111,
shouldShow: (project) => project.project_type === 'modpack',
message: async () =>
(await import('../messages/title/similarities-modpack.md?raw')).default,
},
{
label: 'Forked project',
weight: 112,
message: async () =>
(await import('../messages/title/similarities-fork.md?raw')).default,
},
],
},
],
},
{
id: 'slug_misused_options',
type: 'multi-select-chips',
label: 'Slug issues?',
suggestedStatus: 'rejected',
severity: 'low',
shouldShow: (project) => hasCustomSlug(project),
options: [
{
label: 'Misused',
weight: 200,
message: async () => (await import('../messages/slug/misused.md?raw')).default,
},
],
},
],
title: 'Are the Name and URL accurate and appropriate?',
id: 'title-&-slug',
text: async (project) => {
let text = (await import('../messages/checklist-text/title-slug/title.md?raw')).default
if (hasCustomSlug(project))
text += (await import('../messages/checklist-text/title-slug/slug.md?raw')).default
return text
},
icon: BookOpenIcon,
guidance_url: 'https://modrinth.com/legal/rules#miscellaneous',
actions: [
{
id: 'title_useless_info',
type: 'button',
label: 'Contains useless info',
weight: 100,
suggestedStatus: 'flagged',
severity: 'low',
message: async () => (await import('../messages/title/useless-info.md?raw')).default,
},
{
id: 'title_minecraft_branding',
type: 'button',
label: 'Minecraft title',
weight: 100,
suggestedStatus: 'flagged',
severity: 'medium',
message: async () => (await import('../messages/title/minecraft-branding.md?raw')).default,
},
{
id: 'title_similarities',
type: 'button',
label: 'Title similarities',
weight: 110,
suggestedStatus: 'flagged',
severity: 'medium',
message: async () => (await import('../messages/title/similarities.md?raw')).default,
enablesActions: [
{
id: 'title_similarities_options',
type: 'multi-select-chips',
label: 'Similarities additional info',
options: [
{
label: 'Modpack named after mod',
weight: 111,
shouldShow: (project) => project.project_type === 'modpack',
message: async () =>
(await import('../messages/title/similarities-modpack.md?raw')).default,
},
{
label: 'Forked project',
weight: 112,
message: async () =>
(await import('../messages/title/similarities-fork.md?raw')).default,
},
],
},
],
},
{
id: 'slug_misused_options',
type: 'multi-select-chips',
label: 'Slug issues?',
suggestedStatus: 'rejected',
severity: 'low',
shouldShow: (project) => hasCustomSlug(project),
options: [
{
label: 'Misused',
weight: 200,
message: async () => (await import('../messages/slug/misused.md?raw')).default,
},
],
},
],
}
export default titleSlug

View File

@@ -1,24 +1,25 @@
import { XIcon } from '@modrinth/assets'
import type { Stage } from '../../types/stage'
const undefinedProjectStage: Stage = {
title: 'This project is undefined!',
id: 'undefined-project',
icon: XIcon,
guidance_url: 'https://modrinth.com/legal/rules#miscellaneous',
navigate: '/versions',
shouldShow: (project) => project.versions.length === 0,
actions: [
{
id: 'undefined_no_versions',
type: 'button',
label: 'No Versions',
weight: -100,
suggestedStatus: 'rejected',
message: async () =>
(await import('../messages/undefined-project/no_versions.md?raw')).default,
},
],
title: 'This project is undefined!',
id: 'undefined-project',
icon: XIcon,
guidance_url: 'https://modrinth.com/legal/rules#miscellaneous',
navigate: '/versions',
shouldShow: (project) => project.versions.length === 0,
actions: [
{
id: 'undefined_no_versions',
type: 'button',
label: 'No Versions',
weight: -100,
suggestedStatus: 'rejected',
message: async () =>
(await import('../messages/undefined-project/no_versions.md?raw')).default,
},
],
}
export default undefinedProjectStage

View File

@@ -1,174 +1,175 @@
import type { Stage } from '../../types/stage'
import type { ButtonAction, DropdownAction, DropdownActionOption } from '../../types/actions'
import { VersionIcon } from '@modrinth/assets'
import type { ButtonAction, DropdownAction, DropdownActionOption } from '../../types/actions'
import type { Stage } from '../../types/stage'
const versions: Stage = {
title: "Are this project's files correct?",
id: 'versions',
icon: VersionIcon,
guidance_url: 'https://modrinth.com/legal/rules#miscellaneous',
navigate: '/versions',
actions: [
{
id: 'versions_incorrect_additional',
type: 'button',
label: 'Incorrect additional files',
weight: 1000,
suggestedStatus: 'flagged',
severity: 'medium',
message: async () =>
(await import('../messages/versions/incorrect_additional_files.md?raw')).default,
} as ButtonAction,
{
id: 'versions_incorrect_project_type',
type: 'button',
label: 'Incorrect Project Type',
suggestedStatus: 'rejected',
severity: 'medium',
weight: -999999,
message: async () => '',
enablesActions: [
{
id: 'versions_incorrect_project_type_options',
type: 'dropdown',
label: 'What type should this project be?',
options: [
{
label: 'Modpack',
weight: 1001,
shouldShow: (project) => project.project_type !== 'modpack',
message: async () =>
(await import('../messages/versions/invalid-modpacks.md?raw')).default,
} as DropdownActionOption,
{
label: 'Resource Pack',
weight: 1001,
shouldShow: (project) => project.project_type !== 'resourcepack',
message: async () =>
(await import('../messages/versions/invalid-resourcepacks.md?raw')).default,
} as DropdownActionOption,
{
label: 'Data Pack',
weight: 1001,
shouldShow: (project) => !project.loaders.includes('datapack'),
message: async () =>
(await import('../messages/versions/invalid-datapacks.md?raw')).default,
} as DropdownActionOption,
],
} as DropdownAction,
],
} as ButtonAction,
{
id: 'versions_alternate_versions',
type: 'button',
label: 'Alternate Versions',
suggestedStatus: 'flagged',
severity: 'medium',
weight: -999999,
message: async () => '',
enablesActions: [
{
id: 'versions_alternate_versions_options',
type: 'dropdown',
label: 'How are the alternate versions distributed?',
options: [
{
label: 'Primary Files',
weight: 1002,
message: async () =>
(await import('../messages/versions/alternate_versions-primary.md?raw')).default,
} as DropdownActionOption,
{
label: 'Additional Files',
weight: 1002,
message: async () =>
(await import('../messages/versions/alternate_versions-additional.md?raw')).default,
} as DropdownActionOption,
{
label: 'Monofile',
weight: 1002,
shouldShow: (project) =>
project.project_type === 'resourcepack' || project.loaders.includes('datapack'),
message: async () =>
(await import('../messages/versions/alternate_versions-mono.md?raw')).default,
} as DropdownActionOption,
{
label: 'Server Files (Primary Files)',
weight: 1002,
shouldShow: (project) => project.project_type === 'modpack',
message: async () =>
(await import('../messages/versions/alternate_versions-server.md?raw')).default,
} as DropdownActionOption,
{
label: 'Server Files (Additional Files)',
weight: 1002,
suggestedStatus: 'rejected',
severity: 'high',
shouldShow: (project) => project.project_type === 'modpack',
message: async () =>
(await import('../messages/versions/alternate_versions-server-additional.md?raw'))
.default,
} as DropdownActionOption,
{
label: 'mods.zip',
weight: 1002,
suggestedStatus: 'rejected',
severity: 'high',
shouldShow: (project) => project.project_type === 'modpack',
message: async () =>
(await import('../messages/versions/alternate_versions-zip.md?raw')).default,
} as DropdownActionOption,
],
} as DropdownAction,
],
} as ButtonAction,
{
id: 'versions_vanilla_assets',
type: 'button',
label: 'Vanilla Assets',
suggestedStatus: `rejected`,
severity: `medium`,
weight: 1003,
shouldShow: (project) => project.project_type === 'resourcepack',
message: async () => (await import('../messages/versions/vanilla_assets.md?raw')).default,
} as ButtonAction,
{
id: 'versions_redist_libs',
type: 'button',
label: 'Packed Libs',
suggestedStatus: `rejected`,
severity: `medium`,
weight: 1003,
shouldShow: (project) => project.project_type === 'mod' || project.project_type === 'plugin',
message: async () => (await import('../messages/versions/redist_libs.md?raw')).default,
} as ButtonAction,
{
id: 'versions_duplicate_primary_files',
type: 'button',
label: 'Duplicate Primary Files',
suggestedStatus: 'flagged',
severity: `medium`,
weight: 1004,
message: async () => (await import('../messages/versions/broken_version.md?raw')).default,
} as ButtonAction,
{
id: 'unsupported_project_type',
type: 'button',
label: `Unsupported`,
suggestedStatus: `rejected`,
severity: `medium`,
weight: 1005,
message: async () =>
(await import('../messages/versions/unsupported_project.md?raw')).default,
relevantExtraInput: [
{
label: 'Project Type',
required: true,
variable: 'INVALID_TYPE',
},
],
} as ButtonAction,
],
title: "Are this project's files correct?",
id: 'versions',
icon: VersionIcon,
guidance_url: 'https://modrinth.com/legal/rules#miscellaneous',
navigate: '/versions',
actions: [
{
id: 'versions_incorrect_additional',
type: 'button',
label: 'Incorrect additional files',
weight: 1000,
suggestedStatus: 'flagged',
severity: 'medium',
message: async () =>
(await import('../messages/versions/incorrect_additional_files.md?raw')).default,
} as ButtonAction,
{
id: 'versions_incorrect_project_type',
type: 'button',
label: 'Incorrect Project Type',
suggestedStatus: 'rejected',
severity: 'medium',
weight: -999999,
message: async () => '',
enablesActions: [
{
id: 'versions_incorrect_project_type_options',
type: 'dropdown',
label: 'What type should this project be?',
options: [
{
label: 'Modpack',
weight: 1001,
shouldShow: (project) => project.project_type !== 'modpack',
message: async () =>
(await import('../messages/versions/invalid-modpacks.md?raw')).default,
} as DropdownActionOption,
{
label: 'Resource Pack',
weight: 1001,
shouldShow: (project) => project.project_type !== 'resourcepack',
message: async () =>
(await import('../messages/versions/invalid-resourcepacks.md?raw')).default,
} as DropdownActionOption,
{
label: 'Data Pack',
weight: 1001,
shouldShow: (project) => !project.loaders.includes('datapack'),
message: async () =>
(await import('../messages/versions/invalid-datapacks.md?raw')).default,
} as DropdownActionOption,
],
} as DropdownAction,
],
} as ButtonAction,
{
id: 'versions_alternate_versions',
type: 'button',
label: 'Alternate Versions',
suggestedStatus: 'flagged',
severity: 'medium',
weight: -999999,
message: async () => '',
enablesActions: [
{
id: 'versions_alternate_versions_options',
type: 'dropdown',
label: 'How are the alternate versions distributed?',
options: [
{
label: 'Primary Files',
weight: 1002,
message: async () =>
(await import('../messages/versions/alternate_versions-primary.md?raw')).default,
} as DropdownActionOption,
{
label: 'Additional Files',
weight: 1002,
message: async () =>
(await import('../messages/versions/alternate_versions-additional.md?raw')).default,
} as DropdownActionOption,
{
label: 'Monofile',
weight: 1002,
shouldShow: (project) =>
project.project_type === 'resourcepack' || project.loaders.includes('datapack'),
message: async () =>
(await import('../messages/versions/alternate_versions-mono.md?raw')).default,
} as DropdownActionOption,
{
label: 'Server Files (Primary Files)',
weight: 1002,
shouldShow: (project) => project.project_type === 'modpack',
message: async () =>
(await import('../messages/versions/alternate_versions-server.md?raw')).default,
} as DropdownActionOption,
{
label: 'Server Files (Additional Files)',
weight: 1002,
suggestedStatus: 'rejected',
severity: 'high',
shouldShow: (project) => project.project_type === 'modpack',
message: async () =>
(await import('../messages/versions/alternate_versions-server-additional.md?raw'))
.default,
} as DropdownActionOption,
{
label: 'mods.zip',
weight: 1002,
suggestedStatus: 'rejected',
severity: 'high',
shouldShow: (project) => project.project_type === 'modpack',
message: async () =>
(await import('../messages/versions/alternate_versions-zip.md?raw')).default,
} as DropdownActionOption,
],
} as DropdownAction,
],
} as ButtonAction,
{
id: 'versions_vanilla_assets',
type: 'button',
label: 'Vanilla Assets',
suggestedStatus: `rejected`,
severity: `medium`,
weight: 1003,
shouldShow: (project) => project.project_type === 'resourcepack',
message: async () => (await import('../messages/versions/vanilla_assets.md?raw')).default,
} as ButtonAction,
{
id: 'versions_redist_libs',
type: 'button',
label: 'Packed Libs',
suggestedStatus: `rejected`,
severity: `medium`,
weight: 1003,
shouldShow: (project) => project.project_type === 'mod' || project.project_type === 'plugin',
message: async () => (await import('../messages/versions/redist_libs.md?raw')).default,
} as ButtonAction,
{
id: 'versions_duplicate_primary_files',
type: 'button',
label: 'Duplicate Primary Files',
suggestedStatus: 'flagged',
severity: `medium`,
weight: 1004,
message: async () => (await import('../messages/versions/broken_version.md?raw')).default,
} as ButtonAction,
{
id: 'unsupported_project_type',
type: 'button',
label: `Unsupported`,
suggestedStatus: `rejected`,
severity: `medium`,
weight: 1005,
message: async () =>
(await import('../messages/versions/unsupported_project.md?raw')).default,
relevantExtraInput: [
{
label: 'Project Type',
required: true,
variable: 'INVALID_TYPE',
},
],
} as ButtonAction,
],
}
export default versions

View File

@@ -1,14 +1,13 @@
export * from './types/actions'
export * from './types/messages'
export * from './types/stage'
export * from './types/keybinds'
export * from './types/nags'
export * from './types/reports'
export * from './utils'
export * from './data/nags/index'
export { default as nags } from './data/nags'
export { finalPermissionMessages } from './data/modpack-permissions-stage'
export { default as checklist } from './data/checklist'
export { default as keybinds } from './data/keybinds'
export { finalPermissionMessages } from './data/modpack-permissions-stage'
export { default as nags } from './data/nags'
export * from './data/nags/index'
export { default as reportQuickReplies } from './data/report-quick-replies'
export * from './types/actions'
export * from './types/keybinds'
export * from './types/messages'
export * from './types/nags'
export * from './types/reports'
export * from './types/stage'
export * from './utils'

View File

@@ -1,264 +1,265 @@
import type { Project } from '@modrinth/utils'
import type { WeightedMessage } from './messages'
export type ActionType =
| 'button'
| 'dropdown'
| 'multi-select-chips'
| 'toggle'
| 'conditional-button'
| 'button'
| 'dropdown'
| 'multi-select-chips'
| 'toggle'
| 'conditional-button'
export type Action =
| ButtonAction
| DropdownAction
| MultiSelectChipsAction
| ToggleAction
| ConditionalButtonAction
| ButtonAction
| DropdownAction
| MultiSelectChipsAction
| ToggleAction
| ConditionalButtonAction
export type ModerationStatus = 'approved' | 'rejected' | 'flagged'
export type ModerationSeverity = 'low' | 'medium' | 'high' | 'critical'
export interface BaseAction {
/**
* The type of action, which determines how the action is presented to the moderator and what it does.
*/
type: ActionType
/**
* The type of action, which determines how the action is presented to the moderator and what it does.
*/
type: ActionType
/**
* Any additional text data that is required to complete the action.
*/
relevantExtraInput?: AdditionalTextInput[]
/**
* Any additional text data that is required to complete the action.
*/
relevantExtraInput?: AdditionalTextInput[]
/**
* Suggested moderation status when this action is selected.
*/
suggestedStatus?: ModerationStatus
/**
* Suggested moderation status when this action is selected.
*/
suggestedStatus?: ModerationStatus
/**
* Suggested severity level for this moderation action.
*/
severity?: ModerationSeverity
/**
* Suggested severity level for this moderation action.
*/
severity?: ModerationSeverity
/**
* Actions that become available when this action is selected.
*/
enablesActions?: Action[]
/**
* Actions that become available when this action is selected.
*/
enablesActions?: Action[]
/**
* Actions that become unavailable when this action is selected.
*/
disablesActions?: string[] // Array of action IDs
/**
* Actions that become unavailable when this action is selected.
*/
disablesActions?: string[] // Array of action IDs
/**
* Unique identifier for this action, used for conditional logic.
*/
id?: string
/**
* Unique identifier for this action, used for conditional logic.
*/
id?: string
/**
* A function that determines whether this action should be shown for a given project.
*
* By default, it returns `true`, meaning the action is always shown.
*/
shouldShow?: (project: Project) => boolean
/**
* A function that determines whether this action should be shown for a given project.
*
* By default, it returns `true`, meaning the action is always shown.
*/
shouldShow?: (project: Project) => boolean
}
/**
* Represents a conditional message that changes based on other selected actions.
*/
export interface ConditionalMessage extends WeightedMessage {
/**
* Conditions that must be met for this message to be used.
*/
conditions: {
/**
* Action IDs that must be selected for this message to apply.
*/
requiredActions?: string[]
/**
* Conditions that must be met for this message to be used.
*/
conditions: {
/**
* Action IDs that must be selected for this message to apply.
*/
requiredActions?: string[]
/**
* Action IDs that must NOT be selected for this message to apply.
*/
excludedActions?: string[]
}
/**
* Action IDs that must NOT be selected for this message to apply.
*/
excludedActions?: string[]
}
}
/**
* Represents a button action, which is a simple toggle button that can be used to append a message to the final moderation message.
*/
export interface ButtonAction extends BaseAction, WeightedMessage {
type: 'button'
type: 'button'
/**
* The label of the button, which is displayed to the moderator. The text on the button.
*/
label: string
/**
* The label of the button, which is displayed to the moderator. The text on the button.
*/
label: string
/**
* Alternative messages based on other selected actions.
*/
conditionalMessages?: ConditionalMessage[]
/**
* Alternative messages based on other selected actions.
*/
conditionalMessages?: ConditionalMessage[]
}
/**
* Represents a simple toggle/checkbox action with separate layout handling.
*/
export interface ToggleAction extends BaseAction, WeightedMessage {
type: 'toggle'
type: 'toggle'
/**
* The label of the toggle, which is displayed to the moderator.
*/
label: string
/**
* The label of the toggle, which is displayed to the moderator.
*/
label: string
/**
* Description text that appears below the toggle.
*/
description?: string
/**
* Description text that appears below the toggle.
*/
description?: string
/**
* Whether the toggle is checked by default.
*/
defaultChecked?: boolean
/**
* Whether the toggle is checked by default.
*/
defaultChecked?: boolean
/**
* Alternative messages based on other selected actions.
*/
conditionalMessages?: ConditionalMessage[]
/**
* Alternative messages based on other selected actions.
*/
conditionalMessages?: ConditionalMessage[]
}
/**
* Represents a button that has different behavior based on other selected actions.
*/
export interface ConditionalButtonAction extends BaseAction {
type: 'conditional-button'
type: 'conditional-button'
/**
* The label of the button, which is displayed to the moderator.
*/
label: string
/**
* The label of the button, which is displayed to the moderator.
*/
label: string
/**
* Different message configurations based on conditions.
*/
messageVariants: ConditionalMessage[]
/**
* Different message configurations based on conditions.
*/
messageVariants: ConditionalMessage[]
/**
* Global fallback message if no variants match their conditions.
*/
fallbackMessage?: () => Promise<string>
/**
* Global fallback message if no variants match their conditions.
*/
fallbackMessage?: () => Promise<string>
/**
* The weight of the action's fallback message, used to determine the place where the message is placed in the final moderation message.
*/
fallbackWeight?: number
/**
* The weight of the action's fallback message, used to determine the place where the message is placed in the final moderation message.
*/
fallbackWeight?: number
}
export interface DropdownActionOption extends WeightedMessage {
/**
* The label of the option, which is displayed to the moderator.
*/
label: string
/**
* The label of the option, which is displayed to the moderator.
*/
label: string
/**
* A function that determines whether this option should be shown for a given project.
*
* By default, it returns `true`, meaning the option is always shown.
*/
shouldShow?: (project: Project) => boolean
/**
* A function that determines whether this option should be shown for a given project.
*
* By default, it returns `true`, meaning the option is always shown.
*/
shouldShow?: (project: Project) => boolean
}
export interface DropdownAction extends BaseAction {
type: 'dropdown'
type: 'dropdown'
/**
* The label associated with the dropdown.
*/
label: string
/**
* The label associated with the dropdown.
*/
label: string
/**
* The options available in the dropdown.
*/
options: DropdownActionOption[]
/**
* The options available in the dropdown.
*/
options: DropdownActionOption[]
/**
* The default option selected in the dropdown, by index.
*/
defaultOption?: number
/**
* The default option selected in the dropdown, by index.
*/
defaultOption?: number
}
export interface MultiSelectChipsOption extends WeightedMessage {
/**
* The label of the chip, which is displayed to the moderator.
*/
label: string
/**
* The label of the chip, which is displayed to the moderator.
*/
label: string
/**
* A function that determines whether this option should be shown for a given project.
*
* By default, it returns `true`, meaning the option is always shown.
*/
shouldShow?: (project: Project) => boolean
/**
* A function that determines whether this option should be shown for a given project.
*
* By default, it returns `true`, meaning the option is always shown.
*/
shouldShow?: (project: Project) => boolean
}
export interface MultiSelectChipsAction extends BaseAction {
type: 'multi-select-chips'
type: 'multi-select-chips'
/**
* The label associated with the multi-select chips.
*/
label: string
/**
* The label associated with the multi-select chips.
*/
label: string
/**
* The options available in the multi-select chips.
*/
options: MultiSelectChipsOption[]
/**
* The options available in the multi-select chips.
*/
options: MultiSelectChipsOption[]
}
export interface AdditionalTextInput {
/**
* The label of the text input, which is displayed to the moderator.
*/
label: string
/**
* The label of the text input, which is displayed to the moderator.
*/
label: string
/**
* The placeholder text for the text input.
*/
placeholder?: string
/**
* The placeholder text for the text input.
*/
placeholder?: string
/**
* Whether the text input is required to be filled out before the action can be completed.
*/
required?: boolean
/**
* Whether the text input is required to be filled out before the action can be completed.
*/
required?: boolean
/**
* Whether the text input should use the full markdown editor rather than a simple text input.
*/
large?: boolean
/**
* Whether the text input should use the full markdown editor rather than a simple text input.
*/
large?: boolean
/**
* The variable name that will be replaced in the message with the input value.
* For example, if variable is "MESSAGE", then "%MESSAGE%" in the action message
* will be replaced with the input value.
*/
variable?: string
/**
* The variable name that will be replaced in the message with the input value.
* For example, if variable is "MESSAGE", then "%MESSAGE%" in the action message
* will be replaced with the input value.
*/
variable?: string
/**
* Conditions that determine when this input is shown.
*/
showWhen?: {
/**
* Action IDs that must be selected for this input to be shown.
*/
requiredActions?: string[]
/**
* Conditions that determine when this input is shown.
*/
showWhen?: {
/**
* Action IDs that must be selected for this input to be shown.
*/
requiredActions?: string[]
/**
* Action IDs that must NOT be selected for this input to be shown.
*/
excludedActions?: string[]
}
/**
* Action IDs that must NOT be selected for this input to be shown.
*/
excludedActions?: string[]
}
/**
* Optional suggestions for the input. Useful for repeating phrases or common responses.
*/
suggestions?: string[]
/**
* Optional suggestions for the input. Useful for repeating phrases or common responses.
*/
suggestions?: string[]
}

View File

@@ -1,136 +1,136 @@
import type { Project } from '@modrinth/utils'
export interface ModerationActions {
tryGoNext: () => void
tryGoBack: () => void
tryGenerateMessage: () => void
trySkipProject: () => void
tryGoNext: () => void
tryGoBack: () => void
tryGenerateMessage: () => void
trySkipProject: () => void
tryToggleCollapse: () => void
tryResetProgress: () => void
tryExitModeration: () => void
tryToggleCollapse: () => void
tryResetProgress: () => void
tryExitModeration: () => void
tryApprove: () => void
tryReject: () => void
tryWithhold: () => void
tryEditMessage: () => void
tryApprove: () => void
tryReject: () => void
tryWithhold: () => void
tryEditMessage: () => void
tryToggleAction: (actionIndex: number) => void
trySelectDropdownOption: (actionIndex: number, optionIndex: number) => void
tryToggleChip: (actionIndex: number, chipIndex: number) => void
tryToggleAction: (actionIndex: number) => void
trySelectDropdownOption: (actionIndex: number, optionIndex: number) => void
tryToggleChip: (actionIndex: number, chipIndex: number) => void
tryFocusNextAction: () => void
tryFocusPreviousAction: () => void
tryActivateFocusedAction: () => void
tryFocusNextAction: () => void
tryFocusPreviousAction: () => void
tryActivateFocusedAction: () => void
}
export interface ModerationState {
currentStage: number
totalStages: number
currentStageId: string | undefined
currentStageTitle: string
currentStage: number
totalStages: number
currentStageId: string | undefined
currentStageTitle: string
isCollapsed: boolean
isDone: boolean
hasGeneratedMessage: boolean
isLoadingMessage: boolean
isModpackPermissionsStage: boolean
isCollapsed: boolean
isDone: boolean
hasGeneratedMessage: boolean
isLoadingMessage: boolean
isModpackPermissionsStage: boolean
futureProjectCount: number
visibleActionsCount: number
futureProjectCount: number
visibleActionsCount: number
focusedActionIndex: number | null
focusedActionType: 'button' | 'toggle' | 'dropdown' | 'multi-select' | null
focusedActionIndex: number | null
focusedActionType: 'button' | 'toggle' | 'dropdown' | 'multi-select' | null
}
export interface ModerationContext {
project: Project
state: ModerationState
actions: ModerationActions
project: Project
state: ModerationState
actions: ModerationActions
}
export interface KeybindDefinition {
key: string
ctrl?: boolean
shift?: boolean
alt?: boolean
meta?: boolean
preventDefault?: boolean
key: string
ctrl?: boolean
shift?: boolean
alt?: boolean
meta?: boolean
preventDefault?: boolean
}
export interface KeybindListener {
id: string
keybind: KeybindDefinition | KeybindDefinition[] | string | string[]
description: string
enabled?: (ctx: ModerationContext) => boolean
action: (ctx: ModerationContext) => void
id: string
keybind: KeybindDefinition | KeybindDefinition[] | string | string[]
description: string
enabled?: (ctx: ModerationContext) => boolean
action: (ctx: ModerationContext) => void
}
export function parseKeybind(keybindString: string): KeybindDefinition {
const parts = keybindString.split('+').map((p) => p.trim().toLowerCase())
const parts = keybindString.split('+').map((p) => p.trim().toLowerCase())
return {
key: parts.find((p) => !['ctrl', 'shift', 'alt', 'meta', 'cmd'].includes(p)) || '',
ctrl: parts.includes('ctrl') || parts.includes('cmd'),
shift: parts.includes('shift'),
alt: parts.includes('alt'),
meta: parts.includes('meta') || parts.includes('cmd'),
preventDefault: true,
}
return {
key: parts.find((p) => !['ctrl', 'shift', 'alt', 'meta', 'cmd'].includes(p)) || '',
ctrl: parts.includes('ctrl') || parts.includes('cmd'),
shift: parts.includes('shift'),
alt: parts.includes('alt'),
meta: parts.includes('meta') || parts.includes('cmd'),
preventDefault: true,
}
}
export function normalizeKeybind(keybind: KeybindDefinition | string): KeybindDefinition {
return typeof keybind === 'string' ? parseKeybind(keybind) : keybind
return typeof keybind === 'string' ? parseKeybind(keybind) : keybind
}
export function matchesKeybind(event: KeyboardEvent, keybind: KeybindDefinition | string): boolean {
const def = normalizeKeybind(keybind)
return (
event.key.toLowerCase() === def.key.toLowerCase() &&
event.ctrlKey === (def.ctrl ?? false) &&
event.shiftKey === (def.shift ?? false) &&
event.altKey === (def.alt ?? false) &&
event.metaKey === (def.meta ?? false)
)
const def = normalizeKeybind(keybind)
return (
event.key.toLowerCase() === def.key.toLowerCase() &&
event.ctrlKey === (def.ctrl ?? false) &&
event.shiftKey === (def.shift ?? false) &&
event.altKey === (def.alt ?? false) &&
event.metaKey === (def.meta ?? false)
)
}
export function handleKeybind(
event: KeyboardEvent,
ctx: ModerationContext,
keybinds: KeybindListener[],
event: KeyboardEvent,
ctx: ModerationContext,
keybinds: KeybindListener[],
): boolean {
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement ||
(event.target as HTMLElement)?.closest('.cm-editor') ||
(event.target as HTMLElement)?.classList?.contains('cm-content') ||
(event.target as HTMLElement)?.classList?.contains('cm-line')
) {
return false
}
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement ||
(event.target as HTMLElement)?.closest('.cm-editor') ||
(event.target as HTMLElement)?.classList?.contains('cm-content') ||
(event.target as HTMLElement)?.classList?.contains('cm-line')
) {
return false
}
for (const keybind of keybinds) {
if (keybind.enabled && !keybind.enabled(ctx)) {
continue
}
for (const keybind of keybinds) {
if (keybind.enabled && !keybind.enabled(ctx)) {
continue
}
const keybindDefs = Array.isArray(keybind.keybind)
? keybind.keybind.map(normalizeKeybind)
: [normalizeKeybind(keybind.keybind)]
const keybindDefs = Array.isArray(keybind.keybind)
? keybind.keybind.map(normalizeKeybind)
: [normalizeKeybind(keybind.keybind)]
const matches = keybindDefs.some((def) => matchesKeybind(event, def))
const matches = keybindDefs.some((def) => matchesKeybind(event, def))
if (matches) {
keybind.action(ctx)
if (matches) {
keybind.action(ctx)
const shouldPrevent = keybindDefs.some((def) => def.preventDefault !== false)
if (shouldPrevent) {
event.preventDefault()
}
const shouldPrevent = keybindDefs.some((def) => def.preventDefault !== false)
if (shouldPrevent) {
event.preventDefault()
}
return true
}
}
return true
}
}
return false
return false
}

View File

@@ -1,13 +1,13 @@
export interface WeightedMessage {
/**
* The weight of the action's active message, used to determine the place where the message is placed in the final moderation message.
*/
weight: number
/**
* The weight of the action's active message, used to determine the place where the message is placed in the final moderation message.
*/
weight: number
/**
* The message which is appended to the final moderation message if the button is active.
* @returns A function that lazily loads the message which is appended if the button is active.
* @example async () => (await import('../messages/example.md?raw')).default,
*/
message: () => Promise<string>
/**
* The message which is appended to the final moderation message if the button is active.
* @returns A function that lazily loads the message which is appended if the button is active.
* @example async () => (await import('../messages/example.md?raw')).default,
*/
message: () => Promise<string>
}

View File

@@ -17,80 +17,80 @@ export type NagStatus = 'required' | 'warning' | 'suggestion' | 'special-submit-
* This context is used to determine whether a nag or it's link should be shown and how it should be presented.
*/
export interface NagContext {
/**
* The project associated with the nag.
*/
project: Project
/**
* The versions associated with the project.
*/
versions: Version[]
/**
* The current project member viewing the nag.
*/
currentMember: 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 */
/**
* The project associated with the nag.
*/
project: Project
/**
* The versions associated with the project.
*/
versions: Version[]
/**
* The current project member viewing the nag.
*/
currentMember: 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 */
}
/**
* Interface representing a nag's link.
*/
export interface NagLink {
/**
* A relative path to the nag's link, e.g. '/settings'.
*/
path: string
/**
* The text to display for the nag's link.
*/
title: MessageDescriptor | string
/**
* The status of the nag, which can be 'required', 'warning', or 'suggestion'.
*/
shouldShow?: (context: NagContext) => boolean
/**
* A relative path to the nag's link, e.g. '/settings'.
*/
path: string
/**
* The text to display for the nag's link.
*/
title: MessageDescriptor | string
/**
* The status of the nag, which can be 'required', 'warning', or 'suggestion'.
*/
shouldShow?: (context: NagContext) => boolean
}
/**
* Interface representing a nag.
*/
export interface Nag {
/**
* A unique identifier for the nag.
*/
id: string
/**
* The title of the nag.
*/
title: MessageDescriptor | string
/**
* A function that returns the description of the nag.
* It can accept a context to provide dynamic descriptions.
*/
description: MessageDescriptor | ((context: NagContext) => string)
/**
* The status of the nag, which can be 'required', 'warning', or 'suggestion'.
*/
status: NagStatus
/**
* An optional icon for the nag, usually from `@modrinth/assets`.
* If not specified it will use the default icon associated with the nag status.
*/
icon?: FunctionalComponent<SVGAttributes>
/**
* A unique identifier for the nag.
*/
id: string
/**
* The title of the nag.
*/
title: MessageDescriptor | string
/**
* A function that returns the description of the nag.
* It can accept a context to provide dynamic descriptions.
*/
description: MessageDescriptor | ((context: NagContext) => string)
/**
* The status of the nag, which can be 'required', 'warning', or 'suggestion'.
*/
status: NagStatus
/**
* An optional icon for the nag, usually from `@modrinth/assets`.
* If not specified it will use the default icon associated with the nag status.
*/
icon?: FunctionalComponent<SVGAttributes>
/**
* A function that determines whether the nag should be shown based on the context.
*/
shouldShow: (context: NagContext) => boolean
/**
* An optional link associated with the nag.
* If provided, it should be displayed alongside the nag.
*/
link?: NagLink
/**
* A function that determines whether the nag should be shown based on the context.
*/
shouldShow: (context: NagContext) => boolean
/**
* An optional link associated with the nag.
* If provided, it should be displayed alongside the nag.
*/
link?: NagLink
}

View File

@@ -1,28 +1,28 @@
import type { Project, Report, Thread, User, Version, DelphiReport } from '@modrinth/utils'
import type { DelphiReport, Project, Report, Thread, User, Version } from '@modrinth/utils'
export interface OwnershipTarget {
name: string
slug: string
avatar_url?: string
type: 'user' | 'organization'
name: string
slug: string
avatar_url?: string
type: 'user' | 'organization'
}
export interface ExtendedReport extends Report {
thread: Thread
reporter_user: User
project?: Project
user?: User
version?: Version
target?: OwnershipTarget
thread: Thread
reporter_user: User
project?: Project
user?: User
version?: Version
target?: OwnershipTarget
}
export interface ExtendedDelphiReport extends DelphiReport {
target?: OwnershipTarget
target?: OwnershipTarget
}
export interface ReportQuickReply {
label: string
message: string | ((report: ExtendedReport) => Promise<string> | string)
shouldShow?: (report: ExtendedReport) => boolean
private?: boolean
label: string
message: string | ((report: ExtendedReport) => Promise<string> | string)
shouldShow?: (report: ExtendedReport) => boolean
private?: boolean
}

View File

@@ -1,52 +1,53 @@
import type { Project } from '@modrinth/utils'
import type { Action } from './actions'
import type { FunctionalComponent, SVGAttributes } from 'vue'
import type { Action } from './actions'
/**
* Represents a moderation stage with associated actions and optional navigation logic.
*/
export interface Stage {
/**
* The title of the stage, displayed to the moderator.
*/
title: string
/**
* The title of the stage, displayed to the moderator.
*/
title: string
/**
* An optional description or additional text for the stage.
*/
text?: (project: Project) => Promise<string>
/**
* An optional description or additional text for the stage.
*/
text?: (project: Project) => Promise<string>
/**
* Optional id for the stage, used for identification in the checklist. Will be used in the stage list as well instead of the title.
*/
id?: string
/**
* Optional id for the stage, used for identification in the checklist. Will be used in the stage list as well instead of the title.
*/
id?: string
/**
* Optional icon for the stage, displayed in the stage list and next to the title.
*/
icon?: FunctionalComponent<SVGAttributes>
/**
* Optional icon for the stage, displayed in the stage list and next to the title.
*/
icon?: FunctionalComponent<SVGAttributes>
/**
* URL to the guidance document for this stage.
*/
guidance_url: string
/**
* URL to the guidance document for this stage.
*/
guidance_url: string
/**
* An array of actions that can be taken in this stage.
*/
actions: Action[]
/**
* An array of actions that can be taken in this stage.
*/
actions: Action[]
/**
* Optional navigation path to redirect the moderator when this stage is shown.
*
* This is relative to the project page. For example, `/settings#side-types` would navigate to `https://modrinth.com/project/:id/settings#side-types`.
*/
navigate?: string
/**
* Optional navigation path to redirect the moderator when this stage is shown.
*
* This is relative to the project page. For example, `/settings#side-types` would navigate to `https://modrinth.com/project/:id/settings#side-types`.
*/
navigate?: string
/**
* A function that determines whether this stage should be shown for a given project.
*
* By default, it returns `true`, meaning the stage is always shown.
*/
shouldShow?: (project: Project) => boolean
/**
* A function that determines whether this stage should be shown for a given project.
*
* By default, it returns `true`, meaning the stage is always shown.
*/
shouldShow?: (project: Project) => boolean
}

View File

@@ -1,345 +1,346 @@
import type { Project } from '@modrinth/utils'
import type {
Action,
AdditionalTextInput,
ButtonAction,
ConditionalMessage,
ToggleAction,
Action,
AdditionalTextInput,
ButtonAction,
ConditionalMessage,
ToggleAction,
} from './types/actions'
export interface ActionState {
selected: boolean
value?: Set<number> | number | string | unknown
selected: boolean
value?: Set<number> | number | string | unknown
}
export interface MessagePart {
weight: number
content: string
actionId: string
stageIndex: number
weight: number
content: string
actionId: string
stageIndex: number
}
export type SerializedActionState = {
isSet?: boolean
isSet?: boolean
} & ActionState
export function getActionIdForStage(
action: Action,
stageIndex: number,
actionIndex?: number,
enabledIndex?: number,
action: Action,
stageIndex: number,
actionIndex?: number,
enabledIndex?: number,
): string {
if (action.id) {
return `stage-${stageIndex}-${action.id}`
}
const suffix = enabledIndex !== undefined ? `-enabled-${enabledIndex}` : ''
return `stage-${stageIndex}-action-${actionIndex}${suffix}`
if (action.id) {
return `stage-${stageIndex}-${action.id}`
}
const suffix = enabledIndex !== undefined ? `-enabled-${enabledIndex}` : ''
return `stage-${stageIndex}-action-${actionIndex}${suffix}`
}
export function getActionId(action: Action, currentStage: number, index?: number): string {
return getActionIdForStage(action, currentStage, index)
return getActionIdForStage(action, currentStage, index)
}
export function getActionKey(
action: Action,
currentStage: number,
visibleActions: Action[],
action: Action,
currentStage: number,
visibleActions: Action[],
): string {
const index = visibleActions.indexOf(action)
return `${currentStage}-${index}-${getActionId(action, currentStage)}`
const index = visibleActions.indexOf(action)
return `${currentStage}-${index}-${getActionId(action, currentStage)}`
}
export function serializeActionStates(states: Record<string, ActionState>): string {
const serializable: Record<string, SerializedActionState> = {}
for (const [key, state] of Object.entries(states)) {
serializable[key] = {
selected: state.selected,
value: state.value instanceof Set ? Array.from(state.value) : state.value,
isSet: state.value instanceof Set,
}
}
return JSON.stringify(serializable)
const serializable: Record<string, SerializedActionState> = {}
for (const [key, state] of Object.entries(states)) {
serializable[key] = {
selected: state.selected,
value: state.value instanceof Set ? Array.from(state.value) : state.value,
isSet: state.value instanceof Set,
}
}
return JSON.stringify(serializable)
}
export function deserializeActionStates(data: string): Record<string, ActionState> {
try {
const parsed = JSON.parse(data)
const states: Record<string, ActionState> = {}
for (const [key, state] of Object.entries(parsed as Record<string, SerializedActionState>)) {
states[key] = {
selected: state.selected,
value: state.isSet ? new Set(state.value as unknown[]) : state.value,
}
}
return states
} catch {
return {}
}
try {
const parsed = JSON.parse(data)
const states: Record<string, ActionState> = {}
for (const [key, state] of Object.entries(parsed as Record<string, SerializedActionState>)) {
states[key] = {
selected: state.selected,
value: state.isSet ? new Set(state.value as unknown[]) : state.value,
}
}
return states
} catch {
return {}
}
}
export function initializeActionState(action: Action): ActionState {
if (action.type === 'toggle') {
return {
selected: action.defaultChecked || false,
}
} else if (action.type === 'dropdown') {
return {
selected: true,
value: action.defaultOption || 0,
}
} else if (action.type === 'multi-select-chips') {
return {
selected: false,
value: new Set<number>(),
}
} else {
return {
selected: false,
}
}
if (action.type === 'toggle') {
return {
selected: action.defaultChecked || false,
}
} else if (action.type === 'dropdown') {
return {
selected: true,
value: action.defaultOption || 0,
}
} else if (action.type === 'multi-select-chips') {
return {
selected: false,
value: new Set<number>(),
}
} else {
return {
selected: false,
}
}
}
export function processMessage(
message: string,
action: Action,
stageIndex: number,
textInputValues: Record<string, string>,
message: string,
action: Action,
stageIndex: number,
textInputValues: Record<string, string>,
): string {
let processedMessage = message
let processedMessage = message
if (action.relevantExtraInput) {
action.relevantExtraInput.forEach((input, index) => {
if (input.variable) {
const inputKey = `stage-${stageIndex}-${action.id || `action-${index}`}-${index}`
const value = textInputValues[inputKey] || ''
if (action.relevantExtraInput) {
action.relevantExtraInput.forEach((input, index) => {
if (input.variable) {
const inputKey = `stage-${stageIndex}-${action.id || `action-${index}`}-${index}`
const value = textInputValues[inputKey] || ''
const regex = new RegExp(`%${input.variable}%`, 'g')
processedMessage = processedMessage.replace(regex, value)
}
})
}
const regex = new RegExp(`%${input.variable}%`, 'g')
processedMessage = processedMessage.replace(regex, value)
}
})
}
return processedMessage
return processedMessage
}
export function findMatchingVariant(
variants: ConditionalMessage[],
selectedActionIds: string[],
allValidActionIds?: string[],
currentStageIndex?: number,
variants: ConditionalMessage[],
selectedActionIds: string[],
allValidActionIds?: string[],
currentStageIndex?: number,
): ConditionalMessage | null {
for (const variant of variants) {
const conditions = variant.conditions
for (const variant of variants) {
const conditions = variant.conditions
const meetsRequired =
!conditions.requiredActions ||
conditions.requiredActions.every((id) => {
let fullId = id
if (currentStageIndex !== undefined && !id.startsWith('stage-')) {
fullId = `stage-${currentStageIndex}-${id}`
}
const meetsRequired =
!conditions.requiredActions ||
conditions.requiredActions.every((id) => {
let fullId = id
if (currentStageIndex !== undefined && !id.startsWith('stage-')) {
fullId = `stage-${currentStageIndex}-${id}`
}
if (allValidActionIds && !allValidActionIds.includes(fullId)) {
return false
}
return selectedActionIds.includes(fullId)
})
if (allValidActionIds && !allValidActionIds.includes(fullId)) {
return false
}
return selectedActionIds.includes(fullId)
})
const meetsExcluded =
!conditions.excludedActions ||
!conditions.excludedActions.some((id) => {
let fullId = id
if (currentStageIndex !== undefined && !id.startsWith('stage-')) {
fullId = `stage-${currentStageIndex}-${id}`
}
return selectedActionIds.includes(fullId)
})
const meetsExcluded =
!conditions.excludedActions ||
!conditions.excludedActions.some((id) => {
let fullId = id
if (currentStageIndex !== undefined && !id.startsWith('stage-')) {
fullId = `stage-${currentStageIndex}-${id}`
}
return selectedActionIds.includes(fullId)
})
if (meetsRequired && meetsExcluded) {
return variant
}
}
if (meetsRequired && meetsExcluded) {
return variant
}
}
return null
return null
}
export async function getActionMessage(
action: ButtonAction | ToggleAction,
selectedActionIds: string[],
allValidActionIds?: string[],
action: ButtonAction | ToggleAction,
selectedActionIds: string[],
allValidActionIds?: string[],
): Promise<string> {
if (action.conditionalMessages && action.conditionalMessages.length > 0) {
const matchingConditional = findMatchingVariant(
action.conditionalMessages,
selectedActionIds,
allValidActionIds,
)
if (matchingConditional) {
return (await matchingConditional.message()) as string
}
}
if (action.conditionalMessages && action.conditionalMessages.length > 0) {
const matchingConditional = findMatchingVariant(
action.conditionalMessages,
selectedActionIds,
allValidActionIds,
)
if (matchingConditional) {
return (await matchingConditional.message()) as string
}
}
return (await action.message()) as string
return (await action.message()) as string
}
export function getVisibleInputs(
action: Action,
actionStates: Record<string, ActionState>,
action: Action,
actionStates: Record<string, ActionState>,
): AdditionalTextInput[] {
if (!action.relevantExtraInput) return []
if (!action.relevantExtraInput) return []
const selectedActionIds = Object.entries(actionStates)
.filter(([, state]) => state.selected)
.map(([id]) => id)
const selectedActionIds = Object.entries(actionStates)
.filter(([, state]) => state.selected)
.map(([id]) => id)
return action.relevantExtraInput.filter((input) => {
if (!input.showWhen) return true
return action.relevantExtraInput.filter((input) => {
if (!input.showWhen) return true
const meetsRequired =
!input.showWhen.requiredActions ||
input.showWhen.requiredActions.every((id) => selectedActionIds.includes(id))
const meetsRequired =
!input.showWhen.requiredActions ||
input.showWhen.requiredActions.every((id) => selectedActionIds.includes(id))
const meetsExcluded =
!input.showWhen.excludedActions ||
!input.showWhen.excludedActions.some((id) => selectedActionIds.includes(id))
const meetsExcluded =
!input.showWhen.excludedActions ||
!input.showWhen.excludedActions.some((id) => selectedActionIds.includes(id))
return meetsRequired && meetsExcluded
})
return meetsRequired && meetsExcluded
})
}
export function expandVariables(
template: string,
project: Project,
variables?: Record<string, string>,
template: string,
project: Project,
variables?: Record<string, string>,
): string {
if (!variables) {
variables = flattenProjectVariables(project)
}
if (!variables) {
variables = flattenProjectVariables(project)
}
return Object.entries(variables).reduce((result, [key, value]) => {
const variable = `%${key}%`
return result.replace(new RegExp(variable, 'g'), value)
}, template)
return Object.entries(variables).reduce((result, [key, value]) => {
const variable = `%${key}%`
return result.replace(new RegExp(variable, 'g'), value)
}, template)
}
export function kebabToTitleCase(input: string): string {
return input
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
return input
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
export function arrayOrNone(arr: string[]): string {
return arr.length > 0 ? arr.join(', ') : 'None'
return arr.length > 0 ? arr.join(', ') : 'None'
}
export function flattenProjectVariables(project: Project): Record<string, string> {
const vars: Record<string, string> = {}
const vars: Record<string, string> = {}
vars['PROJECT_ID'] = project.id
vars['PROJECT_TYPE'] = project.project_type
vars['PROJECT_SLUG'] = project.slug
vars['PROJECT_TITLE'] = project.title
vars['PROJECT_SUMMARY'] = project.description
vars['PROJECT_STATUS'] = project.status
vars['PROJECT_REQUESTED_STATUS'] = project.requested_status
vars['PROJECT_MONETIZATION_STATUS'] = project.monetization_status
vars['PROJECT_BODY'] = project.body
vars['PROJECT_ID'] = project.id
vars['PROJECT_TYPE'] = project.project_type
vars['PROJECT_SLUG'] = project.slug
vars['PROJECT_TITLE'] = project.title
vars['PROJECT_SUMMARY'] = project.description
vars['PROJECT_STATUS'] = project.status
vars['PROJECT_REQUESTED_STATUS'] = project.requested_status
vars['PROJECT_MONETIZATION_STATUS'] = project.monetization_status
vars['PROJECT_BODY'] = project.body
vars['PROJECT_ICON_URL'] = project.icon_url || ''
vars['PROJECT_ISSUES_URL'] = project.issues_url || 'None'
vars['PROJECT_SOURCE_URL'] = project.source_url || 'None'
vars['PROJECT_WIKI_URL'] = project.wiki_url || 'None'
vars['PROJECT_DISCORD_URL'] = project.discord_url || 'None'
vars['PROJECT_ICON_URL'] = project.icon_url || ''
vars['PROJECT_ISSUES_URL'] = project.issues_url || 'None'
vars['PROJECT_SOURCE_URL'] = project.source_url || 'None'
vars['PROJECT_WIKI_URL'] = project.wiki_url || 'None'
vars['PROJECT_DISCORD_URL'] = project.discord_url || 'None'
vars['PROJECT_DOWNLOADS'] = project.downloads.toString()
vars['PROJECT_FOLLOWERS'] = project.followers.toString()
vars['PROJECT_COLOR'] = project.color?.toString() || ''
vars['PROJECT_DOWNLOADS'] = project.downloads.toString()
vars['PROJECT_FOLLOWERS'] = project.followers.toString()
vars['PROJECT_COLOR'] = project.color?.toString() || ''
vars['PROJECT_CLIENT_SIDE'] = project.client_side
vars['PROJECT_SERVER_SIDE'] = project.server_side
vars['PROJECT_CLIENT_SIDE'] = project.client_side
vars['PROJECT_SERVER_SIDE'] = project.server_side
vars['PROJECT_TEAM'] = project.team || 'None'
vars['PROJECT_THREAD_ID'] = project.thread_id
vars['PROJECT_ORGANIZATION'] = project.organization
vars['PROJECT_TEAM'] = project.team || 'None'
vars['PROJECT_THREAD_ID'] = project.thread_id
vars['PROJECT_ORGANIZATION'] = project.organization
vars['PROJECT_PUBLISHED'] = project.published
vars['PROJECT_UPDATED'] = project.updated
vars['PROJECT_APPROVED'] = project.approved
vars['PROJECT_QUEUED'] = project.queued
vars['PROJECT_PUBLISHED'] = project.published
vars['PROJECT_UPDATED'] = project.updated
vars['PROJECT_APPROVED'] = project.approved
vars['PROJECT_QUEUED'] = project.queued
vars['PROJECT_LICENSE_ID'] = project.license.id
vars['PROJECT_LICENSE_NAME'] = project.license.name
vars['PROJECT_LICENSE_URL'] = project.license.url || 'None'
vars['PROJECT_LICENSE_ID'] = project.license.id
vars['PROJECT_LICENSE_NAME'] = project.license.name
vars['PROJECT_LICENSE_URL'] = project.license.url || 'None'
vars['PROJECT_CATEGORIES'] = arrayOrNone(project.categories)
vars['PROJECT_ADDITIONAL_CATEGORIES'] = arrayOrNone(project.additional_categories)
vars['PROJECT_GAME_VERSIONS'] = arrayOrNone(project.game_versions)
vars['PROJECT_LOADERS'] = arrayOrNone(project.loaders)
vars['PROJECT_VERSIONS'] = arrayOrNone(project.versions)
vars['PROJECT_CATEGORIES'] = arrayOrNone(project.categories)
vars['PROJECT_ADDITIONAL_CATEGORIES'] = arrayOrNone(project.additional_categories)
vars['PROJECT_GAME_VERSIONS'] = arrayOrNone(project.game_versions)
vars['PROJECT_LOADERS'] = arrayOrNone(project.loaders)
vars['PROJECT_VERSIONS'] = arrayOrNone(project.versions)
vars['PROJECT_CATEGORIES_COUNT'] = project.categories.length.toString()
vars['PROJECT_GAME_VERSIONS_COUNT'] = project.game_versions.length.toString()
vars['PROJECT_LOADERS_COUNT'] = project.loaders.length.toString()
vars['PROJECT_VERSIONS_COUNT'] = project.versions.length.toString()
vars['PROJECT_GALLERY_COUNT'] = (project.gallery?.length || 0).toString()
vars['PROJECT_DONATION_URLS_COUNT'] = project.donation_urls.length.toString()
vars['PROJECT_CATEGORIES_COUNT'] = project.categories.length.toString()
vars['PROJECT_GAME_VERSIONS_COUNT'] = project.game_versions.length.toString()
vars['PROJECT_LOADERS_COUNT'] = project.loaders.length.toString()
vars['PROJECT_VERSIONS_COUNT'] = project.versions.length.toString()
vars['PROJECT_GALLERY_COUNT'] = (project.gallery?.length || 0).toString()
vars['PROJECT_DONATION_URLS_COUNT'] = project.donation_urls.length.toString()
project.donation_urls.forEach((donation, index) => {
vars[`PROJECT_DONATION_${index}_ID`] = donation.id
vars[`PROJECT_DONATION_${index}_PLATFORM`] = donation.platform
vars[`PROJECT_DONATION_${index}_URL`] = donation.url
})
project.donation_urls.forEach((donation, index) => {
vars[`PROJECT_DONATION_${index}_ID`] = donation.id
vars[`PROJECT_DONATION_${index}_PLATFORM`] = donation.platform
vars[`PROJECT_DONATION_${index}_URL`] = donation.url
})
project.gallery?.forEach((image, index) => {
vars[`PROJECT_GALLERY_${index}_URL`] = image.url
vars[`PROJECT_GALLERY_${index}_TITLE`] = image.title || ''
vars[`PROJECT_GALLERY_${index}_DESCRIPTION`] = image.description || ''
vars[`PROJECT_GALLERY_${index}_FEATURED`] = image.featured.toString()
})
project.gallery?.forEach((image, index) => {
vars[`PROJECT_GALLERY_${index}_URL`] = image.url
vars[`PROJECT_GALLERY_${index}_TITLE`] = image.title || ''
vars[`PROJECT_GALLERY_${index}_DESCRIPTION`] = image.description || ''
vars[`PROJECT_GALLERY_${index}_FEATURED`] = image.featured.toString()
})
// Static time saving stuff
vars[`RULES`] = `[Modrinth's Content Rules](https://modrinth.com/legal/rules)`
vars[`TOS`] = `[Terms of Use](https://modrinth.com/legal/terms)`
vars[`COPYRIGHT_POLICY`] = `[Copyright Policy](https://modrinth.com/legal/copyright)`
vars[`SUPPORT`] =
`please visit the [Modrinth Help Center](https://support.modrinth.com/) and click the green bubble to contact support.`
vars[`MODPACK_PERMISSIONS_GUIDE`] =
`our guide to [Obtaining Modpack Permissions](https://support.modrinth.com/en/articles/8797527-obtaining-modpack-permissions)`
vars[`MODPACKS_ON_MODRINTH`] =
`[Modpacks on Modrinth](https://support.modrinth.com/en/articles/8802250-modpacks-on-modrinth)`
vars[`ADVANCED_MARKDOWN`] =
`[Markdown Formatting Guide](https://support.modrinth.com/en/articles/8801962-advanced-markdown-formatting)`
vars[`LICENSING_GUIDE`] =
`our guide to [Licensing your Mods](https://modrinth.com/news/article/licensing-guide)`
// Static time saving stuff
vars[`RULES`] = `[Modrinth's Content Rules](https://modrinth.com/legal/rules)`
vars[`TOS`] = `[Terms of Use](https://modrinth.com/legal/terms)`
vars[`COPYRIGHT_POLICY`] = `[Copyright Policy](https://modrinth.com/legal/copyright)`
vars[`SUPPORT`] =
`please visit the [Modrinth Help Center](https://support.modrinth.com/) and click the green bubble to contact support.`
vars[`MODPACK_PERMISSIONS_GUIDE`] =
`our guide to [Obtaining Modpack Permissions](https://support.modrinth.com/en/articles/8797527-obtaining-modpack-permissions)`
vars[`MODPACKS_ON_MODRINTH`] =
`[Modpacks on Modrinth](https://support.modrinth.com/en/articles/8802250-modpacks-on-modrinth)`
vars[`ADVANCED_MARKDOWN`] =
`[Markdown Formatting Guide](https://support.modrinth.com/en/articles/8801962-advanced-markdown-formatting)`
vars[`LICENSING_GUIDE`] =
`our guide to [Licensing your Mods](https://modrinth.com/news/article/licensing-guide)`
// Navigation related variables
vars[`PROJECT_PERMANENT_LINK`] = `https://modrinth.com/project/${project.id}`
vars[`PROJECT_SETTINGS_LINK`] = `https://modrinth.com/project/${project.id}/settings`
vars[`PROJECT_SETTINGS_FLINK`] = `[Settings](https://modrinth.com/project/${project.id}/settings)`
vars[`PROJECT_TITLE_FLINK`] = `[Name](https://modrinth.com/project/${project.id}/settings)`
vars[`PROJECT_SLUG_FLINK`] = `[URL](https://modrinth.com/project/${project.id}/settings)`
vars[`PROJECT_SUMMARY_FLINK`] = `[Summary](https://modrinth.com/project/${project.id}/settings)`
vars[`PROJECT_ENVIRONMENT_FLINK`] =
`[Environment Information](https://modrinth.com/project/${project.id}/settings)`
vars[`PROJECT_TAGS_LINK`] = `https://modrinth.com/project/${project.id}/settings/tags`
vars[`PROJECT_TAGS_FLINK`] = `[Tags](https://modrinth.com/project/${project.id}/settings/tags)`
vars[`PROJECT_DESCRIPTION_LINK`] =
`https://modrinth.com/project/${project.id}/settings/description`
vars[`PROJECT_DESCRIPTION_FLINK`] =
`[Description](https://modrinth.com/project/${project.id}/settings/description)`
vars[`PROJECT_LICENSE_LINK`] = `https://modrinth.com/project/${project.id}/license`
vars[`PROJECT_LICENSE_FLINK`] = `[License](https://modrinth.com/project/${project.id}/license)`
vars[`PROJECT_LINKS_LINK`] = `https://modrinth.com/project/${project.id}/settings/links`
vars[`PROJECT_LINKS_FLINK`] =
`[External Links](https://modrinth.com/project/${project.id}/settings/links)`
vars[`PROJECT_GALLERY_LINK`] = `https://modrinth.com/project/${project.id}/gallery`
vars[`PROJECT_GALLERY_FLINK`] = `[Gallery](https://modrinth.com/project/${project.id}/gallery)`
vars[`PROJECT_VERSIONS_LINK`] = `https://modrinth.com/project/${project.id}/versions`
vars[`PROJECT_VERSIONS_FLINK`] = `[Versions](https://modrinth.com/project/${project.id}/versions)`
vars[`PROJECT_MODERATION_LINK`] = `https://modrinth.com/project/${project.id}/moderation`
vars[`PROJECT_MODERATION_FLINK`] =
`[moderation tab](https://modrinth.com/project/${project.id}/moderation)`
// Navigation related variables
vars[`PROJECT_PERMANENT_LINK`] = `https://modrinth.com/project/${project.id}`
vars[`PROJECT_SETTINGS_LINK`] = `https://modrinth.com/project/${project.id}/settings`
vars[`PROJECT_SETTINGS_FLINK`] = `[Settings](https://modrinth.com/project/${project.id}/settings)`
vars[`PROJECT_TITLE_FLINK`] = `[Name](https://modrinth.com/project/${project.id}/settings)`
vars[`PROJECT_SLUG_FLINK`] = `[URL](https://modrinth.com/project/${project.id}/settings)`
vars[`PROJECT_SUMMARY_FLINK`] = `[Summary](https://modrinth.com/project/${project.id}/settings)`
vars[`PROJECT_ENVIRONMENT_FLINK`] =
`[Environment Information](https://modrinth.com/project/${project.id}/settings)`
vars[`PROJECT_TAGS_LINK`] = `https://modrinth.com/project/${project.id}/settings/tags`
vars[`PROJECT_TAGS_FLINK`] = `[Tags](https://modrinth.com/project/${project.id}/settings/tags)`
vars[`PROJECT_DESCRIPTION_LINK`] =
`https://modrinth.com/project/${project.id}/settings/description`
vars[`PROJECT_DESCRIPTION_FLINK`] =
`[Description](https://modrinth.com/project/${project.id}/settings/description)`
vars[`PROJECT_LICENSE_LINK`] = `https://modrinth.com/project/${project.id}/license`
vars[`PROJECT_LICENSE_FLINK`] = `[License](https://modrinth.com/project/${project.id}/license)`
vars[`PROJECT_LINKS_LINK`] = `https://modrinth.com/project/${project.id}/settings/links`
vars[`PROJECT_LINKS_FLINK`] =
`[External Links](https://modrinth.com/project/${project.id}/settings/links)`
vars[`PROJECT_GALLERY_LINK`] = `https://modrinth.com/project/${project.id}/gallery`
vars[`PROJECT_GALLERY_FLINK`] = `[Gallery](https://modrinth.com/project/${project.id}/gallery)`
vars[`PROJECT_VERSIONS_LINK`] = `https://modrinth.com/project/${project.id}/versions`
vars[`PROJECT_VERSIONS_FLINK`] = `[Versions](https://modrinth.com/project/${project.id}/versions)`
vars[`PROJECT_MODERATION_LINK`] = `https://modrinth.com/project/${project.id}/moderation`
vars[`PROJECT_MODERATION_FLINK`] =
`[moderation tab](https://modrinth.com/project/${project.id}/moderation)`
return vars
return vars
}

View File

@@ -1,10 +1,3 @@
{
"extends": "tsconfig/vue.json",
"include": [".", ".eslintrc.js"],
"exclude": ["dist", "build", "node_modules"],
"compilerOptions": {
"lib": ["esnext", "dom"],
"noImplicitAny": false
},
"types": ["@stripe/stripe-js"]
"extends": "@modrinth/tooling-config/typescript/vue.json"
}

View File

@@ -1,4 +1,4 @@
declare module '*.md?raw' {
const content: string
export default content
const content: string
export default content
}