feat: move validation logic to use labrinth

This commit is contained in:
tdgao
2026-09-03 10:51:16 -06:00
parent 157e0da6df
commit 5bf9bd89b2
87 changed files with 1552 additions and 6505 deletions
+8 -42
View File
@@ -21,8 +21,9 @@ The package is organized as follows:
│ │ ├── description.ts # Description stage definition
│ │ └── ... # One file per stage
│ └── nags/ # Publishing checklist (nag system) files
│ ├── core.ts # Core nags (required fields, basic validation)
── ...
│ ├── index.ts # Backend-kind registry and presentation adapters
── name.ts # Name nag copy
│ └── ... # One copy file per settings area
└── types/ # Type definitions
├── actions.ts # Action-related types (moderation)
├── messages.ts # Message-related types (moderation)
@@ -160,7 +161,7 @@ relevantExtraInput: [
## Publishing Checklist (Nag System)
The nag system provides automated feedback to project authors during the submission process, helping them improve their projects before they reach moderation. It analyzes project data and provides suggestions, warnings, and requirements.
The nag system presents automated feedback returned by Labrinth's project validation endpoint. Labrinth decides which nags apply and their severity; this package owns localized copy and navigation metadata.
### Nags
@@ -169,45 +170,19 @@ A nag represents a specific issue or suggestion for improvement. Each nag has:
- A unique `id` for identification
- A `title` and `description` displayed to the user
- A `status` indicating severity: `'required'`, `'warning'`, or `'suggestion'`
- A `shouldShow` function that determines when the nag should be displayed
- An optional `link` to help users address the issue
### Internationalization
Use vintl's `defineMessage` syntax.
If you want to use context in the messages, you can do so like this:
```typescript
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
return formatMessage(defineMessage(...), {
length: context.project.body?.length || 0,
minChars: MIN_DESCRIPTION_CHARS,
})
}
```
### Nag Context
The `NagContext` type provides access to:
- `project`: Current project data
- `versions`: Project versions
- `tags`: Frontend "tags" (generated state)
- `currentRoute`: Current page route
- and other data...
### Adding New Nags
To add a new nag:
1. Add the nag definition to the appropriate category file (or make a new category file and add it to `data/nags.ts`)
2. Add corresponding i18n messages to the `.i18n.ts` file
3. Implement the `shouldShow` logic based on project state
4. Add appropriate links to help users resolve the issue
5. Run `pnpm run fix` to fix lint issues & generate the root locale index.json file.
1. Add the nag copy to the appropriate area file in `data/nags/` and register its backend kind in `data/nags/index.ts`.
2. Assign the settings destination for the affected field.
3. Run `pnpm run fix` to fix lint issues and generate the root locale index.json file.
Example:
@@ -217,16 +192,7 @@ Example:
id: 'new-nag',
title: messages.newNagTitle,
description: messages.newNagDescription,
status: 'warning',
shouldShow: (context: NagContext) => {
// Your validation logic here
return someCondition
},
link: {
path: 'settings/description',
title: messages.editDescriptionTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings-description',
},
destination: 'description',
}
```
+1 -9
View File
@@ -7,27 +7,19 @@
"scripts": {
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .",
"test": "node --test src/validators/*/tests.ts src/validation-rules/tests.ts",
"test": "node --test src/data/nags/tests.ts",
"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",
"intl:prune-local": "pnpm -w scripts i18n-icu-contract prune-local --scope packages/moderation"
},
"dependencies": {
"@modrinth/api-client": "workspace:*",
"@modrinth/assets": "workspace:*",
"@modrinth/utils": "workspace:*",
"@tanstack/vue-query": "5.101.4",
"franc-min": "^6.2.0",
"linkify-it": "^5.0.0",
"node-html-parser": "^9.0.1",
"obscenity": "^0.4.6",
"tlds": "^1.261.0",
"vue": "^3.5.13"
},
"devDependencies": {
"@formatjs/cli": "^6.2.12",
"@modrinth/tooling-config": "workspace:*",
"@modrinth/ui": "workspace:*",
"@types/linkify-it": "^5.0.0",
"typescript": "^5.4.5"
}
}
-32
View File
@@ -1,32 +0,0 @@
import type { Nag, ProjectValidationContext } from '../types/nags.ts'
import { getDescriptionNags } from '../validation-rules/rules/description.ts'
import { getDisclosureNags } from '../validation-rules/rules/disclosures.ts'
import { getGalleryNags } from '../validation-rules/rules/gallery.ts'
import { getIconNags } from '../validation-rules/rules/icon.ts'
import { getLicenseNags } from '../validation-rules/rules/license.ts'
import { getLinksNags } from '../validation-rules/rules/links.ts'
import { getModerationNags } from '../validation-rules/rules/moderation.ts'
import { getNameNags } from '../validation-rules/rules/name.ts'
import { getPermissionsNags } from '../validation-rules/rules/permissions.ts'
import { getServerSettingsNags } from '../validation-rules/rules/server-settings.ts'
import { getSummaryNags } from '../validation-rules/rules/summary.ts'
import { getTagsNags } from '../validation-rules/rules/tags.ts'
import { getVersionNags } from '../validation-rules/rules/versions.ts'
export function getNags(context: ProjectValidationContext): Nag[] {
return [
...getNameNags(context),
...getSummaryNags(context),
...getIconNags(context),
...getGalleryNags(context),
...getDescriptionNags(context),
...getLicenseNags(context),
...getLinksNags(context),
...getPermissionsNags(context),
...getServerSettingsNags(context),
...getTagsNags(context),
...getVersionNags(context),
...getDisclosureNags(context),
...getModerationNags(context),
]
}
@@ -0,0 +1,137 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { NagDefinitions } from './types.ts'
const messages = defineMessages({
addTitle: { id: 'nags.add-description.title', defaultMessage: 'Add a description' },
add: {
id: 'nags.add-description.description',
defaultMessage: `A description that clearly describes your project's content, purpose, and appeal is required.`,
},
adjacentTitle: { id: 'nags.adjacent-headers.title', defaultMessage: 'Remove adjacent headers' },
adjacent: {
id: 'nags.adjacent-headers.description',
defaultMessage: 'Headers of the same level should not be placed next to each other.',
},
endsTitle: {
id: 'nags.description-ends-with-header.title',
defaultMessage: 'Remove ending header',
},
ends: {
id: 'nags.description-ends-with-header.description',
defaultMessage: `Your project's description should not end with a header that is not followed by any text.`,
},
shortTitle: { id: 'nags.description-too-short.title', defaultMessage: 'Expand the description' },
short: {
id: 'nags.description-too-short.description',
defaultMessage: `Your project's description is too brief. Add more details to clearly describe the project's content, purpose, and appeal.`,
},
longTitle: { id: 'nags.long-headers.title', defaultMessage: 'Shorten headers' },
long: {
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.',
},
altTitle: { id: 'nags.missing-alt-text.title', defaultMessage: 'Add image alt text' },
alt: {
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.',
},
fixTitle: {
id: 'nags.invalid-project-description.title',
defaultMessage: 'Modify the description',
},
bannedLink: {
id: 'nags.project-description-banned-link.description',
defaultMessage: 'The link “{fullUrl}” is not allowed in project descriptions.',
},
nonEnglish: {
id: 'nags.project-description-non-english.description',
defaultMessage: `Your project's description must be written in English or include an English translation.`,
},
nonStandard: {
id: 'nags.project-description-non-standard-text.description',
defaultMessage: `Excessive use of non-standard text characters, such as “Fancy text” or “Zalgo”, is not allowed in your project's description.`,
},
profanity: {
id: 'nags.project-description-profanity.description',
defaultMessage: `Your project's description cannot contain excessive profanity. Detected: “{value}”.`,
},
slur: {
id: 'nags.project-description-slur.description',
defaultMessage: `Your project's description must not contain offensive terms. Detected: “{value}”.`,
},
spamTitle: {
id: 'nags.project-description-spam.title',
defaultMessage: 'Remove description spam',
},
spam: {
id: 'nags.project-description-spam.description',
defaultMessage:
'Repeated characters, words, or phrases cannot be used to pad a project description.',
},
})
export const descriptionNags = {
'add-description': {
title: messages.addTitle,
description: messages.add,
destination: 'description',
},
'adjacent-headers': {
title: messages.adjacentTitle,
description: messages.adjacent,
destination: 'description',
},
'description-ends-with-header': {
title: messages.endsTitle,
description: messages.ends,
destination: 'description',
},
'description-too-short': {
title: messages.shortTitle,
description: messages.short,
destination: 'description',
},
'long-headers': {
title: messages.longTitle,
description: messages.long,
destination: 'description',
},
'missing-alt-text': {
title: messages.altTitle,
description: messages.alt,
destination: 'description',
},
'project-description-banned-link': {
title: messages.fixTitle,
description: messages.bannedLink,
destination: 'description',
},
'project-description-non-english': {
title: messages.fixTitle,
description: messages.nonEnglish,
destination: 'description',
},
'project-description-non-standard-text': {
title: messages.fixTitle,
description: messages.nonStandard,
destination: 'description',
},
'project-description-profanity': {
title: messages.fixTitle,
description: messages.profanity,
destination: 'description',
},
'project-description-slur': {
title: messages.fixTitle,
description: messages.slur,
destination: 'description',
},
'project-description-spam': {
title: messages.spamTitle,
description: messages.spam,
destination: 'description',
},
} satisfies NagDefinitions
@@ -1,11 +1,11 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { NagDestinationId, NagLink } from '../types/nags.ts'
import type { NagDestinationId, NagLink } from '../../types/nags.ts'
const messages = defineMessages({
description: {
id: 'nags.settings.description.title',
defaultMessage: 'Visit description settings',
id: 'nags.edit-description.title',
defaultMessage: 'Edit description',
},
disclosures: {
id: 'nags.settings.disclosures.title',
@@ -0,0 +1,34 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { NagDefinitions } from './types.ts'
const messages = defineMessages({
checkTitle: { id: 'nags.check-disclosures.title', defaultMessage: 'Review disclosures' },
check: {
id: 'nags.check-disclosures.description',
defaultMessage:
'Make sure users are aware of any important details by filling in content disclosures that apply to your {type}.',
},
formattingTitle: {
id: 'nags.disclosures-special-formatting.title',
defaultMessage: 'Fix disclosure formatting',
},
formatting: {
id: 'nags.disclosures-special-formatting.description',
defaultMessage:
'Content disclosures should not contain HTML, since they can only display inline Markdown and plain text.',
},
})
export const disclosureNags = {
'check-disclosures': {
title: messages.checkTitle,
description: messages.check,
destination: 'disclosures',
},
'disclosures-special-formatting': {
title: messages.formattingTitle,
description: messages.formatting,
destination: 'disclosures',
},
} satisfies NagDefinitions
@@ -0,0 +1,44 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { NagDefinitions } from './types.ts'
const messages = defineMessages({
title: { id: 'nags.invalid-gallery-text.title', defaultMessage: 'Modify gallery image text' },
nonStandard: {
id: 'nags.gallery-text-non-standard.description',
defaultMessage:
'Non-standard text characters, such as “Fancy text” or “Zalgo”, are not allowed in gallery image titles or descriptions.',
},
profanity: {
id: 'nags.gallery-text-profanity.description',
defaultMessage:
'Your gallery image titles and descriptions cannot contain excessive profanity. Detected: “{value}”.',
},
slur: {
id: 'nags.gallery-text-slur.description',
defaultMessage:
'Your gallery image titles and descriptions must not contain offensive terms. Detected: “{value}”.',
},
editGallery: { id: 'nags.edit-gallery.title', defaultMessage: 'Edit gallery' },
})
export const galleryTextNags = {
'gallery-text-non-standard': {
title: messages.title,
description: messages.nonStandard,
destination: 'gallery',
linkTitle: messages.editGallery,
},
'gallery-text-profanity': {
title: messages.title,
description: messages.profanity,
destination: 'gallery',
linkTitle: messages.editGallery,
},
'gallery-text-slur': {
title: messages.title,
description: messages.slur,
destination: 'gallery',
linkTitle: messages.editGallery,
},
} satisfies NagDefinitions
@@ -0,0 +1,48 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { NagDefinitions } from './types.ts'
const messages = defineMessages({
featureTitle: {
id: 'nags.feature-gallery-image.title',
defaultMessage: 'Feature a gallery image',
},
feature: {
id: 'nags.feature-gallery-image.description',
defaultMessage:
'The featured gallery image is often how your project makes its first impression.',
},
uploadTitle: { id: 'nags.upload-gallery-image.title', defaultMessage: 'Upload a gallery image' },
upload: {
id: 'nags.upload-gallery-image.description',
defaultMessage:
'At least one gallery image is required to showcase the content of your {type}.',
},
uploadResourcePack: {
id: 'nags.upload-gallery-image.description-resourcepack',
defaultMessage:
'At least one gallery image is required to showcase the content of your resource pack, except for audio or localization packs. If this describes your pack, please select the appropriate tag.',
},
uploadShader: {
id: 'nags.upload-gallery-image.description-shader',
defaultMessage:
'At least three gallery images are required to showcase the content of your shader in a variety of situations and conditions.',
},
})
export const galleryNags = {
'feature-gallery-image': {
title: messages.featureTitle,
description: messages.feature,
destination: 'gallery',
},
'upload-gallery-image': {
title: messages.uploadTitle,
description: ({ projectType }) => {
if (projectType === 'resourcepack') return messages.uploadResourcePack
if (projectType === 'shader') return messages.uploadShader
return messages.upload
},
destination: 'gallery',
},
} satisfies NagDefinitions
+16
View File
@@ -0,0 +1,16 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { NagDefinitions } from './types.ts'
const messages = defineMessages({
title: { id: 'nags.add-icon.title', defaultMessage: 'Add an icon' },
description: {
id: 'nags.add-icon.description',
defaultMessage:
'Adding a unique, relevant, and engaging icon makes your project identifiable and helps it stand out.',
},
})
export const iconNags = {
'add-icon': { title: messages.title, description: messages.description, destination: 'general' },
} satisfies NagDefinitions
+130
View File
@@ -0,0 +1,130 @@
import type { Labrinth } from '@modrinth/api-client'
import type { Nag, NagStatus } from '../../types/nags.ts'
import { descriptionNags } from './description.ts'
import { nagDestinations } from './destinations.ts'
import { disclosureNags } from './disclosures.ts'
import { galleryNags } from './gallery.ts'
import { galleryTextNags } from './gallery-text.ts'
import { iconNags } from './icon.ts'
import { licenseNags } from './license.ts'
import { linkNags } from './links.ts'
import { moderationNags } from './moderation.ts'
import { nameNags } from './name.ts'
import { permissionNags } from './permissions.ts'
import { serverSettingNags } from './server-settings.ts'
import { summaryNags } from './summary.ts'
import { tagNags } from './tags.ts'
import type { FieldValidationMessage, NagDefinition } from './types.ts'
import { versionNags } from './versions.ts'
export const nagDefinitions = {
...nameNags,
...summaryNags,
...iconNags,
...galleryNags,
...galleryTextNags,
...descriptionNags,
...licenseNags,
...linkNags,
...permissionNags,
...serverSettingNags,
...tagNags,
...versionNags,
...disclosureNags,
...moderationNags,
} satisfies Record<Labrinth.Projects.v3.NormalizedProjectNagKind, NagDefinition>
export { nagDestinations } from './destinations.ts'
export type { FieldValidationMessage } from './types.ts'
export function normalizeProjectNagKind(
kind: string,
): Labrinth.Projects.v3.NormalizedProjectNagKind | null {
const normalized = kind.replaceAll('_', '-') as Labrinth.Projects.v3.NormalizedProjectNagKind
return normalized in nagDefinitions ? normalized : null
}
function toNagStatus(severity: Labrinth.Projects.v3.ProjectNagSeverity): NagStatus {
return severity
}
function getNagDescription(
definition: NagDefinition,
nag: Labrinth.Projects.v3.ProjectNag,
projectType?: string,
) {
return typeof definition.description === 'function'
? definition.description({ nag, projectType })
: definition.description
}
export function getProjectNagValues(
nag: Labrinth.Projects.v3.ProjectNag,
projectType?: string,
): Record<string, string | number | boolean> {
const details = nag.details ?? {}
const camelCaseKey = (key: string) =>
key.replaceAll(/_([a-z])/g, (_, letter) => letter.toUpperCase())
const values = Array.isArray(details.values)
? details.values.join(', ')
: typeof details.values === 'string' || typeof details.values === 'number'
? details.values
: undefined
const tagValues = Array.isArray(details.tags)
? details.tags
: typeof details.tags === 'string'
? details.tags.split('|')
: []
const tags = tagValues
.map((tag) => String(tag).replace('8x-', '8x or lower').replace('512x+', '512x or higher'))
.join(', ')
const formatted: Record<string, string | number | boolean> = {}
for (const [key, value] of Object.entries(details)) {
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
formatted[camelCaseKey(key)] = value
}
}
const detailProjectType =
typeof details.project_type === 'string' ? details.project_type : undefined
const resolvedProjectType = projectType ?? detailProjectType
return {
...formatted,
...(values !== undefined && details.value === undefined ? { value: values } : {}),
...(tagValues.length > 0 ? { tags } : {}),
...(tagValues.length > 0 && details.count === undefined ? { count: tagValues.length } : {}),
...(details.type === undefined && resolvedProjectType ? { type: resolvedProjectType } : {}),
}
}
export function toProjectNag(nag: Labrinth.Projects.v3.ProjectNag, projectType?: string): Nag {
const kind = normalizeProjectNagKind(nag.kind)
if (!kind) throw new Error(`Unknown project nag kind: ${nag.kind}`)
const definition = nagDefinitions[kind]
const destination = nagDestinations[definition.destination]
return {
id: kind,
title: definition.title,
description: getNagDescription(definition, nag, projectType),
status: toNagStatus(nag.severity),
shouldShow: () => true,
link: definition.linkTitle ? { ...destination, title: definition.linkTitle } : destination,
values: getProjectNagValues(nag, projectType),
}
}
export function toProjectFieldMessage(
nag: Labrinth.Projects.v3.ProjectNag,
projectType?: string,
): FieldValidationMessage {
const kind = normalizeProjectNagKind(nag.kind)
if (!kind) throw new Error(`Unknown project nag kind: ${nag.kind}`)
return {
code: kind,
severity: nag.severity === 'required' ? 'error' : nag.severity,
message: getNagDescription(nagDefinitions[kind], nag, projectType),
values: getProjectNagValues(nag, projectType),
}
}
@@ -0,0 +1,58 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { NagDefinitions } from './types.ts'
const messages = defineMessages({
detailsTitle: {
id: 'nags.add-license-details.title',
defaultMessage: 'Add license details',
},
details: {
id: 'nags.add-license-details.description',
defaultMessage: 'Add a valid URL and name or SPDX identifier for your custom license.',
},
urlTitle: { id: 'nags.invalid-license-url.title', defaultMessage: 'Add a valid license link' },
url: {
id: 'nags.invalid-license-url.description.default',
defaultMessage: 'License URL is invalid.',
},
urlDomain: {
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 text.',
},
urlMalformed: {
id: 'nags.invalid-license-url.description.malformed',
defaultMessage:
'Your license URL appears to be malformed. Please provide a valid URL to your license text.',
},
selectTitle: { id: 'nags.select-license.title', defaultMessage: 'Select a license' },
select: {
id: 'nags.select-license.description',
defaultMessage: 'Select the license your {type} is distributed under.',
},
editLicense: { id: 'nags.edit-license.title', defaultMessage: 'Edit license' },
})
export const licenseNags = {
'add-custom-license-details': {
title: messages.detailsTitle,
description: messages.details,
destination: 'license',
},
'invalid-license-url': {
title: messages.urlTitle,
description: ({ nag }) => {
if (typeof nag.details.domain === 'string') return messages.urlDomain
if (nag.details.reason === 'malformed') return messages.urlMalformed
return messages.url
},
destination: 'license',
linkTitle: messages.editLicense,
},
'select-license': {
title: messages.selectTitle,
description: messages.select,
destination: 'license',
},
} satisfies NagDefinitions
@@ -0,0 +1,85 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { NagDefinitions } from './types.ts'
const messages = defineMessages({
addTitle: { id: 'nags.add-links.title', defaultMessage: 'Add external links' },
addServerTitle: { id: 'nags.add-links-server.title', defaultMessage: 'Add external links' },
add: {
id: 'nags.add-links.description',
defaultMessage:
'Add any relevant links to external resources, such as source code, an issue tracker, or a permanent Discord invite.',
},
addServer: {
id: 'nags.add-links-server.description',
defaultMessage:
'Add any relevant links to external resources, such as a website, store, or a permanent Discord invite.',
},
bannedTitle: { id: 'nags.banned-link-usage.title', defaultMessage: 'Remove prohibited links' },
banned: {
id: 'nags.banned-link-usage.description',
defaultMessage: 'The link “{url}” is not allowed as an external link.',
},
gplTitle: { id: 'nags.gpl-license-source-required.title', defaultMessage: 'Provide source code' },
gpl: {
id: 'nags.gpl-license-source-required.description',
defaultMessage: `Your {type}'s license requires source code to be published. Please provide a source code link, add sources files, or change license.`,
},
identicalTitle: { id: 'nags.identical-links.title', defaultMessage: 'Remove identical links' },
identical: {
id: 'nags.identical-links.description',
defaultMessage:
'Some of your external links appear to be identical. Each link should be listed only once and with the appropriate link type.',
},
discordTitle: { id: 'nags.misused-discord-link.title', defaultMessage: 'Move Discord invite' },
discord: {
id: 'nags.misused-discord-link-description',
defaultMessage:
'Discord invites can not be used for other link types. Please only put your Discord link in the Discord Invite link field.',
},
verifyTitle: { id: 'nags.verify-external-links.title', defaultMessage: 'Review external links' },
verify: {
id: 'nags.verify-external-links.description',
defaultMessage:
'Some of your external links may lead to domains that are inappropriate for that link type.',
},
visitLinks: { id: 'nags.visit-links-settings.title', defaultMessage: 'Visit links settings' },
})
export const linkNags = {
'add-links': { title: messages.addTitle, description: messages.add, destination: 'links' },
'add-links-server': {
title: messages.addServerTitle,
description: messages.addServer,
destination: 'links',
},
'banned-link-usage': {
title: messages.bannedTitle,
description: messages.banned,
destination: 'links',
linkTitle: messages.visitLinks,
},
'gpl-license-source-required': {
title: messages.gplTitle,
description: messages.gpl,
destination: 'links',
linkTitle: messages.visitLinks,
},
'identical-links': {
title: messages.identicalTitle,
description: messages.identical,
destination: 'links',
},
'misused-discord-link': {
title: messages.discordTitle,
description: messages.discord,
destination: 'links',
linkTitle: messages.visitLinks,
},
'verify-external-links': {
title: messages.verifyTitle,
description: messages.verify,
destination: 'links',
linkTitle: messages.visitLinks,
},
} satisfies NagDefinitions
@@ -0,0 +1,19 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { NagDefinitions } from './types.ts'
const messages = defineMessages({
title: { id: 'nags.moderator-feedback.title', defaultMessage: 'Review feedback' },
description: {
id: 'nags.moderator-feedback.description',
defaultMessage: 'Review and address all concerns from the moderation team before resubmitting.',
},
})
export const moderationNags = {
'moderator-feedback': {
title: messages.title,
description: messages.description,
destination: 'moderation',
},
} satisfies NagDefinitions
+70
View File
@@ -0,0 +1,70 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { NagDefinitions } from './types.ts'
const messages = defineMessages({
minecraftTitleClauseTitle: {
id: 'nags.minecraft-title-clause.title',
defaultMessage: 'Avoid brand infringement',
},
minecraftTitleClauseDescription: {
id: 'nags.minecraft-title-clause.description',
defaultMessage: `Projects must not use Minecraft's branding or include "Minecraft" as a significant part of the name.`,
},
fixNameTitle: { id: 'nags.invalid-project-name.title', defaultMessage: 'Modify project name' },
fixVersionTitle: {
id: 'nags.project-name-version.title',
defaultMessage: 'Modify project name',
},
editName: { id: 'nags.edit-title.title', defaultMessage: 'Edit name' },
nonStandardText: {
id: 'nags.project-name-non-standard-text.description',
defaultMessage:
'Non-standard text characters, such as “Fancy text” or “Zalgo”, are not allowed in the project name.',
},
profanity: {
id: 'nags.project-name-profanity.description',
defaultMessage: `Your project's name cannot contain profanity. Detected: “{value}”.`,
},
slur: {
id: 'nags.project-name-slur.description',
defaultMessage: `Your project's name must not contain offensive terms. Detected: “{value}”.`,
},
version: {
id: 'project.text-validation.title-version-number',
defaultMessage: 'Project names should not include version numbers.',
},
})
export const nameNags = {
'minecraft-title-clause': {
title: messages.minecraftTitleClauseTitle,
description: messages.minecraftTitleClauseDescription,
destination: 'general',
linkTitle: messages.editName,
},
'project-name-non-standard-text': {
title: messages.fixNameTitle,
description: messages.nonStandardText,
destination: 'general',
linkTitle: messages.editName,
},
'project-name-profanity': {
title: messages.fixNameTitle,
description: messages.profanity,
destination: 'general',
linkTitle: messages.editName,
},
'project-name-slur': {
title: messages.fixNameTitle,
description: messages.slur,
destination: 'general',
linkTitle: messages.editName,
},
'project-name-version': {
title: messages.fixVersionTitle,
description: messages.version,
destination: 'general',
linkTitle: messages.editName,
},
} satisfies NagDefinitions
@@ -0,0 +1,20 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { NagDefinitions } from './types.ts'
const messages = defineMessages({
title: { id: 'nags.review-permissions.title', defaultMessage: 'Check content permission' },
description: {
id: 'nags.review-permissions.description',
defaultMessage:
'Make sure you have provided proof of your permission to distribute any external content in your Modpack.',
},
})
export const permissionNags = {
'review-permissions': {
title: messages.title,
description: messages.description,
destination: 'permissions',
},
} satisfies NagDefinitions
@@ -0,0 +1,79 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { NagDefinitions } from './types.ts'
const messages = defineMessages({
addressTitle: { id: 'nags.add-java-address.title', defaultMessage: 'Add a Java address' },
address: {
id: 'nags.add-java-address.description',
defaultMessage: 'Add the IP address and port Java Edition players can use to join your server.',
},
languagesTitle: {
id: 'nags.all-languages.title',
defaultMessage: 'Select accurate languages',
},
tooManyLanguagesTitle: {
id: 'nags.too-many-languages.title',
defaultMessage: 'Select accurate languages',
},
allLanguages: {
id: 'nags.all-languages.description',
defaultMessage: `You've selected all available language options. Please list only the languages your server actively supports.`,
},
compatibilityTitle: {
id: 'nags.select-compatibility.title',
defaultMessage: 'Select compatibility',
},
compatibility: {
id: 'nags.select-compatibility.description',
defaultMessage:
'Select what versions your server supports, choose a Modpack, or upload your own.',
},
countryTitle: { id: 'nags.select-country.title', defaultMessage: 'Select a region' },
country: {
id: 'nags.select-country.description',
defaultMessage: 'Let players know what region your server is located in.',
},
languageTitle: { id: 'nags.select-language.title', defaultMessage: 'Select a language' },
language: {
id: 'nags.select-language.description',
defaultMessage: 'List the language or languages supported by your server.',
},
tooMany: {
id: 'nags.too-many-languages.description',
defaultMessage: `You've selected {languageCount, plural, one {# language} other {# languages}}. Please list only the languages your server actively supports.`,
},
})
export const serverSettingNags = {
'add-java-address': {
title: messages.addressTitle,
description: messages.address,
destination: 'server',
},
'all-languages': {
title: messages.languagesTitle,
description: messages.allLanguages,
destination: 'server',
},
'select-compatibility': {
title: messages.compatibilityTitle,
description: messages.compatibility,
destination: 'server',
},
'select-country': {
title: messages.countryTitle,
description: messages.country,
destination: 'server',
},
'select-language': {
title: messages.languageTitle,
description: messages.language,
destination: 'server',
},
'too-many-languages': {
title: messages.tooManyLanguagesTitle,
description: messages.tooMany,
destination: 'server',
},
} satisfies NagDefinitions
@@ -0,0 +1,120 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { NagDefinitions } from './types.ts'
const messages = defineMessages({
linksTitle: {
id: 'nags.project-summary-links.title',
defaultMessage: 'Remove summary links',
},
links: {
id: 'nags.project-summary-links.description',
defaultMessage: 'Links, URLs, and IPs should not be placed in the summary. Detected: "{value}"',
},
reviewTitle: {
id: 'nags.project-summary-content.title',
defaultMessage: 'Review the summary',
},
matchesTitle: {
id: 'project.text-validation.summary-matches-title',
defaultMessage: `Your project's summary should provide unique information and not repeat the project's name.`,
},
fixTitle: { id: 'nags.invalid-project-summary.title', defaultMessage: 'Modify the summary' },
nonEnglish: {
id: 'nags.project-summary-non-english.description',
defaultMessage: `Your project's summary must be written in English or include an English translation.`,
},
nonStandardText: {
id: 'nags.project-summary-non-standard-text.description',
defaultMessage:
'Non-standard text characters, such as “Fancy text” or “Zalgo”, are not allowed in the summary.',
},
profanity: {
id: 'nags.project-summary-profanity.description',
defaultMessage: `Your project's summary cannot contain profanity. Detected: “{value}”.`,
},
slur: {
id: 'nags.project-summary-slur.description',
defaultMessage: `Your project's summary must not contain offensive terms. Detected: “{value}”.`,
},
spamTitle: {
id: 'nags.project-summary-spam.title',
defaultMessage: 'Remove summary spam',
},
spam: {
id: 'nags.project-summary-spam.description',
defaultMessage: `Repeated characters, words, or phrases should not be used to pad your project's summary.`,
},
formattingTitle: {
id: 'nags.summary-special-formatting.title',
defaultMessage: 'Fix summary formatting',
},
formatting: {
id: 'nags.summary-special-formatting.description',
defaultMessage:
'Your summary should not contain Markdown or HTML, since it can only display plain text.',
},
shortTitle: { id: 'nags.summary-too-short.title', defaultMessage: 'Expand the summary' },
short: {
id: 'project.text-validation.summary-too-short',
defaultMessage: 'Your summary is too brief. Add a sentence or two that describes your project.',
},
editSummary: { id: 'nags.edit-summary.title', defaultMessage: 'Edit summary' },
})
export const summaryNags = {
'project-summary-links': {
title: messages.linksTitle,
description: messages.links,
destination: 'general',
linkTitle: messages.editSummary,
},
'project-summary-matches-title': {
title: messages.reviewTitle,
description: messages.matchesTitle,
destination: 'general',
linkTitle: messages.editSummary,
},
'project-summary-non-english': {
title: messages.fixTitle,
description: messages.nonEnglish,
destination: 'general',
linkTitle: messages.editSummary,
},
'project-summary-non-standard-text': {
title: messages.fixTitle,
description: messages.nonStandardText,
destination: 'general',
linkTitle: messages.editSummary,
},
'project-summary-profanity': {
title: messages.fixTitle,
description: messages.profanity,
destination: 'general',
linkTitle: messages.editSummary,
},
'project-summary-slur': {
title: messages.fixTitle,
description: messages.slur,
destination: 'general',
linkTitle: messages.editSummary,
},
'project-summary-spam': {
title: messages.spamTitle,
description: messages.spam,
destination: 'general',
linkTitle: messages.editSummary,
},
'summary-special-formatting': {
title: messages.formattingTitle,
description: messages.formatting,
destination: 'general',
linkTitle: messages.editSummary,
},
'summary-too-short': {
title: messages.shortTitle,
description: messages.short,
destination: 'general',
linkTitle: messages.editSummary,
},
} satisfies NagDefinitions
+67
View File
@@ -0,0 +1,67 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { NagDefinitions } from './types.ts'
const messages = defineMessages({
allTitle: { id: 'nags.all-tags-selected.title', defaultMessage: 'Select accurate tags' },
tooManyTitle: { id: 'nags.too-many-tags.title', defaultMessage: 'Select accurate tags' },
tooManyServerTitle: {
id: 'nags.too-many-tags-server.title',
defaultMessage: 'Select accurate tags',
},
all: {
id: 'nags.all-tags-selected.description',
defaultMessage: `You've selected all {totalAvailableTags, plural, one {# available tag} other {# available tags}}. Tags should be used to help users find relevant projects. Please only select relevant tags.`,
},
resolutionTitle: {
id: 'nags.multiple-resolution-tags.title',
defaultMessage: 'Select correct resolution',
},
resolution: {
id: 'nags.multiple-resolution-tags.description',
defaultMessage: `You've selected {count, plural, one {# resolution tag} other {# resolution tags}} ({tags}). Resource packs should typically only have the tag that matches the primary resolution.`,
},
selectTitle: { id: 'nags.select-tags.title', defaultMessage: 'Select tags' },
select: {
id: 'nags.select-tags.description',
defaultMessage:
'Select the tags that correctly apply to your project to help the right users find it.',
},
tooMany: {
id: 'nags.too-many-tags.description',
defaultMessage: `You've selected {tagCount, plural, one {# tag} other {# tags}}. Please reduce to {maxTagCount} or fewer to ensure your project appears in relevant search results.`,
},
tooManyServer: {
id: 'nags.too-many-tags-server.description',
defaultMessage: `You've selected {tagCount, plural, one {# tag} other {# tags}}. Please reduce to {maxTagCount} or fewer to ensure your project appears in relevant search results.`,
},
editTags: { id: 'nags.edit-tags.title', defaultMessage: 'Edit tags' },
})
export const tagNags = {
'all-tags-selected': {
title: messages.allTitle,
description: messages.all,
destination: 'tags',
linkTitle: messages.editTags,
},
'multiple-resolution-tags': {
title: messages.resolutionTitle,
description: messages.resolution,
destination: 'tags',
linkTitle: messages.editTags,
},
'select-tags': { title: messages.selectTitle, description: messages.select, destination: 'tags' },
'too-many-tags': {
title: messages.tooManyTitle,
description: messages.tooMany,
destination: 'tags',
linkTitle: messages.editTags,
},
'too-many-tags-server': {
title: messages.tooManyServerTitle,
description: messages.tooManyServer,
destination: 'tags',
linkTitle: messages.editTags,
},
} satisfies NagDefinitions
@@ -0,0 +1,88 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
getProjectNagValues,
normalizeProjectNagKind,
toProjectFieldMessage,
toProjectNag,
} from './index.ts'
test('normalizes backend snake-case nag kinds', () => {
assert.equal(normalizeProjectNagKind('multiple_resolution_tags'), 'multiple-resolution-tags')
assert.equal(normalizeProjectNagKind('unknown_nag'), null)
})
test('formats backend resolution tags for ICU messages', () => {
assert.deepEqual(
getProjectNagValues({
kind: 'multiple_resolution_tags',
severity: 'warning',
details: { count: 3, tags: '8x-|32x|512x+' },
}),
{
count: 3,
tags: '8x or lower, 32x, 512x or higher',
},
)
})
test('maps snake-case backend details to ICU variable names', () => {
assert.deepEqual(
getProjectNagValues({
kind: 'too_many_tags',
severity: 'warning',
details: {
full_url: 'https://example.com',
language_count: 11,
max_tag_count: 8,
tag_count: 9,
total_available_tags: 20,
},
}),
{
fullUrl: 'https://example.com',
languageCount: 11,
maxTagCount: 8,
tagCount: 9,
totalAvailableTags: 20,
},
)
})
test('maps required backend nags to field errors', () => {
const nag = {
kind: 'project_name_profanity',
severity: 'required',
details: { value: 'example' },
} as const
const message = toProjectFieldMessage(nag)
assert.equal(message.code, 'project-name-profanity')
assert.equal(message.severity, 'error')
assert.deepEqual(message.values, { value: 'example' })
assert.equal(toProjectNag(nag).status, 'required')
})
test('uses project-specific gallery copy', () => {
const message = toProjectFieldMessage(
{
kind: 'upload_gallery_image',
severity: 'required',
details: {},
},
'shader',
)
assert.equal(message.message.id, 'nags.upload-gallery-image.description-shader')
})
test('uses detailed license URL copy when the backend supplies a domain', () => {
const message = toProjectFieldMessage({
kind: 'invalid_license_url',
severity: 'required',
details: { domain: 'example.com' },
})
assert.equal(message.message.id, 'nags.invalid-license-url.description.domain')
})
@@ -0,0 +1,27 @@
import type { Labrinth } from '@modrinth/api-client'
import type { MessageDescriptor } from '@modrinth/ui'
import type { NagDestinationId } from '../../types/nags.ts'
export interface NagDefinition {
title: MessageDescriptor
description:
| MessageDescriptor
| ((context: {
nag: Labrinth.Projects.v3.ProjectNag
projectType?: string
}) => MessageDescriptor)
destination: NagDestinationId
linkTitle?: MessageDescriptor
}
export interface FieldValidationMessage {
code: string
severity: 'error' | 'warning' | 'suggestion'
message: MessageDescriptor
values?: Record<string, string | number | boolean>
}
export type NagDefinitions = Partial<
Record<Labrinth.Projects.v3.NormalizedProjectNagKind, NagDefinition>
>
@@ -0,0 +1,32 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { NagDefinitions } from './types.ts'
const messages = defineMessages({
environmentTitle: {
id: 'nags.select-environment.title',
defaultMessage: 'Select an environment',
},
environment: {
id: 'nags.select-environment.description',
defaultMessage: 'Specify the environment where your project can run.',
},
uploadTitle: { id: 'nags.upload-version.title', defaultMessage: 'Upload a version' },
upload: {
id: 'nags.upload-version.description',
defaultMessage: 'At least one version is required for a project to be submitted for review.',
},
})
export const versionNags = {
'select-environment': {
title: messages.environmentTitle,
description: messages.environment,
destination: 'versions',
},
'upload-version': {
title: messages.uploadTitle,
description: messages.upload,
destination: 'versions',
},
} satisfies NagDefinitions
+1 -6
View File
@@ -1,6 +1,6 @@
export { useStages } from './data/checklist'
export { default as keybinds } from './data/keybinds'
export { getNags } from './data/nags'
export * from './data/nags/index.ts'
export { default as attributionQuickReplies } from './data/quick-replies/permissions-quick-replies'
export { default as reportQuickReplies } from './data/quick-replies/report-quick-replies'
export {
@@ -18,8 +18,3 @@ export * from './types/quick-reply'
export * from './types/reports'
export * from './types/settings'
export * from './utils'
export * from './validation-rules/index.ts'
export * from './validators/language'
export * from './validators/links'
export * from './validators/non-standard-text'
export * from './validators/profanity'
@@ -36,7 +36,7 @@
"defaultMessage": "Add external links"
},
"nags.adjacent-headers.description": {
"defaultMessage": "Headers of the same level should be placed next to each other."
"defaultMessage": "Headers of the same level should not be placed next to each other."
},
"nags.adjacent-headers.title": {
"defaultMessage": "Remove adjacent headers"
@@ -48,7 +48,7 @@
"defaultMessage": "Select accurate languages"
},
"nags.all-tags-selected.description": {
"defaultMessage": "You've selected all {totalAvailableTags, plural, one {# available tag} other {# available tags}}. Tags should be used to help users find relevant projects. Please only select relevant select."
"defaultMessage": "You've selected all {totalAvailableTags, plural, one {# available tag} other {# available tags}}. Tags should be used to help users find relevant projects. Please only select relevant tags."
},
"nags.all-tags-selected.title": {
"defaultMessage": "Select accurate tags"
@@ -306,7 +306,7 @@
"defaultMessage": "Your project's summary must not contain offensive terms. Detected: “{value}”."
},
"nags.project-summary-spam.description": {
"defaultMessage": "Repeated characters, words, or phrases should be used to pad your project's summary."
"defaultMessage": "Repeated characters, words, or phrases should not be used to pad your project's summary."
},
"nags.project-summary-spam.title": {
"defaultMessage": "Remove summary spam"
+2
View File
@@ -96,6 +96,8 @@ export interface Nag {
* It can accept a context to provide dynamic descriptions.
*/
description: MessageDescriptor | ((context: ProjectValidationContext) => string)
/** Values used when formatting a message descriptor description. */
values?: Record<string, string | number | boolean>
/**
* The status of the nag, which can be 'required', 'warning', or 'suggestion'.
*/
@@ -1,25 +0,0 @@
import type { ValidationRuleMatch, ValidationRuleSet } from './types.ts'
// evaluate a set of rules and return the failed rules
export function evaluateRules<Input, Rules extends ValidationRuleSet<Input>>(
input: Input,
rules: Rules,
): ValidationRuleMatch<Extract<keyof Rules, string>>[] {
type RuleCode = Extract<keyof Rules, string>
const codes = Object.keys(rules) as RuleCode[]
return codes.flatMap<ValidationRuleMatch<RuleCode>>((code) => {
const rule = rules[code]
const result = rule.evaluate(input)
if (result.valid) return []
return [
{
code,
message: result.message ?? rule.presentation.message,
rule,
values: result.values ?? {},
},
]
})
}
@@ -1,20 +0,0 @@
export * from './evaluate-rules.ts'
export * from './nag-destinations.ts'
export * from './rules/description.ts'
export * from './rules/disclosures.ts'
export * from './rules/gallery.ts'
export * from './rules/icon.ts'
export * from './rules/license.ts'
export * from './rules/links.ts'
export * from './rules/moderation.ts'
export * from './rules/name.ts'
export * from './rules/permissions.ts'
export * from './rules/server-settings.ts'
export * from './rules/summary.ts'
export * from './rules/tags.ts'
export * from './rules/versions.ts'
export * from './text.ts'
export * from './to-field-messages.ts'
export * from './to-nags.ts'
export * from './types.ts'
export * from './validate-project.ts'
@@ -1,445 +0,0 @@
import { defineMessages } from '@modrinth/ui/i18n'
import { renderString } from '@modrinth/utils/parse.ts'
import LinkifyIt from 'linkify-it'
import { type HTMLElement, type Node, NodeType, parse } from 'node-html-parser'
import tlds from 'tlds' with { type: 'json' }
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { URL_SHORTENERS } from '../../validators/links/block-list.ts'
import {
getLinkHostname,
hostnameMatchesDomain,
isIpAddress,
} from '../../validators/links/syntax-checks.ts'
import { validateSpam } from '../../validators/spam/index.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import {
evaluateEnglishTextBlocks,
evaluateNonStandardText,
evaluateProfanity,
evaluateSlur,
normalizeProjectFieldText,
projectRequiresEnglishText,
} from '../text.ts'
import { toFieldMessages } from '../to-field-messages.ts'
import { toNags } from '../to-nags.ts'
import type { FieldValidationMessage, ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
fixDescription: {
id: 'nags.invalid-project-description.title',
defaultMessage: `Modify the description`,
},
addDescription: {
id: 'nags.add-description.title',
defaultMessage: `Add a description`,
},
expandDescription: {
id: 'nags.description-too-short.title',
defaultMessage: `Expand the description`,
},
removeSpam: {
id: 'nags.project-description-spam.title',
defaultMessage: `Remove description spam`,
},
shortenHeaders: {
id: 'nags.long-headers.title',
defaultMessage: `Shorten headers`,
},
addContentAfterHeader: {
id: 'nags.description-ends-with-header.title',
defaultMessage: `Remove ending header`,
},
separateHeaders: {
id: 'nags.adjacent-headers.title',
defaultMessage: `Remove adjacent headers`,
},
addImageAltText: {
id: 'nags.missing-alt-text.title',
defaultMessage: `Add image alt text`,
},
editDescription: {
id: 'nags.edit-description.title',
defaultMessage: `Edit description`,
},
slur: {
id: 'nags.project-description-slur.description',
defaultMessage: `Your project's description must not contain offensive terms. Detected: “{value}”.`,
},
profanity: {
id: 'nags.project-description-profanity.description',
defaultMessage: `Your project's description cannot contain excessive profanity. Detected: “{value}”.`,
},
nonStandardText: {
id: 'nags.project-description-non-standard-text.description',
defaultMessage: `Excessive use of non-standard text characters, such as “Fancy text” or “Zalgo”, is not allowed in your project's description.`,
},
nonEnglish: {
id: 'nags.project-description-non-english.description',
defaultMessage: `Your project's description must be written in English or include an English translation.`,
},
bannedLink: {
id: 'nags.project-description-banned-link.description',
defaultMessage: `The link “{fullUrl}” is not allowed in project descriptions.`,
},
required: {
id: 'nags.add-description.description',
defaultMessage: `A description that clearly describes your project's content, purpose, and appeal is required.`,
},
tooShort: {
id: 'nags.description-too-short.description',
defaultMessage: `Your project's description is too brief. Add more details to clearly describe the project's content, purpose, and appeal.`,
},
spam: {
id: 'nags.project-description-spam.description',
defaultMessage: `Repeated characters, words, or phrases cannot be used to pad a project description.`,
},
longHeaders: {
id: 'nags.long-headers.description',
defaultMessage: `{count, plural, one {# header} other {# headers}} in your description {count, plural, one {is} other {are}} too long. Headers should be concise and act as section titles, not full sentences.`,
},
descriptionEndsWithHeader: {
id: 'nags.description-ends-with-header.description',
defaultMessage: `Your project's description should not end with a header that is not followed by any text.`,
},
adjacentHeaders: {
id: 'nags.adjacent-headers.description',
defaultMessage: `Headers of the same level should be placed next to each other.`,
},
missingAltText: {
id: 'nags.missing-alt-text.description',
defaultMessage: `Some of your images are missing alt text, which is important for accessibility, especially for visually impaired users.`,
},
})
export const DESCRIPTION_MAX_PROFANITY_COUNT = 2
export const DESCRIPTION_NON_STANDARD_TEXT_FAILURE_THRESHOLD = 0.05
export const MIN_DESCRIPTION_CHARS = 125
export const MAX_HEADER_LENGTH = 80
export const BANNED_DESCRIPTION_LINK_DOMAINS = [...URL_SHORTENERS] as const
const descriptionLinkify = new LinkifyIt({
fuzzyEmail: false,
fuzzyIP: true,
fuzzyLink: true,
}).tlds(tlds)
const headerCharacterSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
export function extractDescriptionLinks(description: string): string[] {
const matches = descriptionLinkify.match(description) ?? []
return [
...new Set(
matches
.map((match) => match.url)
.filter((url) => {
const hostname = getLinkHostname(url)
return hostname === null || !isIpAddress(hostname)
}),
),
]
}
export function findBannedDescriptionLink(description: string): string | null {
for (const url of extractDescriptionLinks(description)) {
const hostname = getLinkHostname(url)
if (
hostname &&
BANNED_DESCRIPTION_LINK_DOMAINS.some((domain) => hostnameMatchesDomain(hostname, domain))
) {
return url
}
}
return null
}
export function extractRenderedHeaders(markdown: string): string[] {
if (!markdown) return []
const renderedDescription = parse(renderString(markdown))
return renderedDescription
.querySelectorAll('h1, h2, h3')
.map((header) => header.textContent.replace(/\s+/g, ' ').trim())
}
function countHeaderCharacters(header: string): number {
return [...headerCharacterSegmenter.segment(header)].length
}
export function analyzeHeaderLength(markdown: string): {
hasLongHeaders: boolean
longHeaders: string[]
} {
const longHeaders = extractRenderedHeaders(markdown).filter(
(header) => countHeaderCharacters(header) > MAX_HEADER_LENGTH,
)
return { hasLongHeaders: longHeaders.length > 0, longHeaders }
}
function isHeader(element: HTMLElement, minimumLevel = 1, maximumLevel = 6): boolean {
const match = /^h([1-6])$/i.exec(element.rawTagName)
if (!match) return false
const level = Number(match[1])
return level >= minimumLevel && level <= maximumLevel
}
function isMeaningfulNode(node: Node): boolean {
return (
node.nodeType === NodeType.ELEMENT_NODE ||
(node.nodeType === NodeType.TEXT_NODE && node.textContent.trim().length > 0)
)
}
function endsWithHeader(element: HTMLElement): boolean {
const lastNode = element.childNodes.findLast(isMeaningfulNode)
if (!lastNode || lastNode.nodeType !== NodeType.ELEMENT_NODE) return false
const lastElement = lastNode as HTMLElement
return isHeader(lastElement) || endsWithHeader(lastElement)
}
export function analyzeHeaderStructure(markdown: string): {
descriptionEndsWithHeader: boolean
hasAdjacentSameLevelHeaders: boolean
} {
if (!markdown) {
return { descriptionEndsWithHeader: false, hasAdjacentSameLevelHeaders: false }
}
const renderedDescription = parse(renderString(markdown))
const hasAdjacentSameLevelHeaders = renderedDescription
.querySelectorAll('h1, h2, h3')
.some((header) => {
const siblings = header.parentNode?.childNodes ?? []
const nextNode = siblings.slice(siblings.indexOf(header) + 1).find(isMeaningfulNode)
return (
nextNode?.nodeType === NodeType.ELEMENT_NODE &&
(nextNode as HTMLElement).rawTagName.toLowerCase() === header.rawTagName.toLowerCase()
)
})
return {
descriptionEndsWithHeader: endsWithHeader(renderedDescription),
hasAdjacentSameLevelHeaders,
}
}
export function extractDescriptionText(markdown: string): string {
if (!markdown) return ''
const withoutCode = markdown.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '')
const withoutImagesAndLinks = withoutCode
.replace(/!\[([^\]]*)]\([^)]+\)/g, '$1')
.replace(/\[[^\]]*]\([^)]+\)/g, ' ')
const withHtmlImageAltText = withoutImagesAndLinks.replace(/<img[^>]*>/gi, (image) => {
const altMatch = image.match(/alt\s*=\s*(?:"([^"]*)"|'([^']*)')/i)
return altMatch?.[1] ?? altMatch?.[2] ?? ' '
})
const withoutHtml = withHtmlImageAltText.replace(/<[^>]+>/g, ' ')
const withoutMarkdownSyntax = withoutHtml
.replace(/^(?:>[ \t]?)+/gm, '')
.replace(/^#{1,6}\s+/gm, ' ')
.replace(/[*_~`>-]/g, ' ')
.replace(/\|/g, ' ')
return withoutMarkdownSyntax.replace(/\s+/g, ' ').trim()
}
export function extractDescriptionTextBlocks(markdown: string): string[] {
if (!markdown) return []
return markdown
.replace(/```[\s\S]*?```/g, '')
.split(/\n\s*\n+/)
.map(extractDescriptionText)
.filter(Boolean)
}
export function countText(markdown: string): number {
return extractDescriptionText(markdown).length
}
export function analyzeImageContent(markdown: string): {
hasEmptyAltText: boolean
} {
if (!markdown) return { hasEmptyAltText: false }
const withoutCodeBlocks = markdown.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '')
const images = [...withoutCodeBlocks.matchAll(/!\[([^\]]*)\]\([^)]+\)/g)]
const htmlImages = [...withoutCodeBlocks.matchAll(/<img[^>]*>/gi)]
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 { hasEmptyAltText }
}
type DescriptionInput = string | null | undefined
const commonNagPresentation = {
destination: 'description',
linkTitle: messages.editDescription,
} as const
export const projectDescriptionValidationRules = {
'project-description-slur': {
severity: 'error',
evaluate: (description) => evaluateSlur(description ?? ''),
presentation: {
message: messages.slur,
nag: { title: messages.fixDescription, ...commonNagPresentation },
},
},
'project-description-profanity': {
severity: 'error',
evaluate: (description) =>
evaluateProfanity(description ?? '', DESCRIPTION_MAX_PROFANITY_COUNT),
presentation: {
message: messages.profanity,
nag: { title: messages.fixDescription, ...commonNagPresentation },
},
},
'project-description-non-standard-text': {
severity: 'error',
evaluate: (description) =>
evaluateNonStandardText(description ?? '', DESCRIPTION_NON_STANDARD_TEXT_FAILURE_THRESHOLD),
presentation: {
message: messages.nonStandardText,
nag: { title: messages.fixDescription, ...commonNagPresentation },
},
},
'project-description-non-english': {
severity: 'error',
evaluate: (description) => {
const blocks = extractDescriptionTextBlocks(description ?? '')
const text = blocks.join(' ')
if (text.length < MIN_DESCRIPTION_CHARS || !validateSpam(text).valid) {
return { valid: true }
}
return evaluateEnglishTextBlocks(blocks)
},
presentation: {
message: messages.nonEnglish,
nag: { title: messages.fixDescription, ...commonNagPresentation },
},
},
'add-description': {
severity: 'error',
evaluate: (description) => ({
valid: normalizeProjectFieldText(description ?? '').length > 0,
}),
presentation: {
message: messages.required,
nag: { title: messages.addDescription, ...commonNagPresentation },
},
},
'description-too-short': {
severity: 'error',
evaluate: (description) => {
const normalized = normalizeProjectFieldText(description ?? '')
if (!normalized) return { valid: true }
const length = countText(normalized)
return length >= MIN_DESCRIPTION_CHARS
? { valid: true }
: { valid: false, values: { length, minChars: MIN_DESCRIPTION_CHARS } }
},
presentation: {
message: messages.tooShort,
nag: { title: messages.expandDescription, ...commonNagPresentation },
},
},
'project-description-spam': {
severity: 'error',
evaluate: (description) => ({
valid: validateSpam(extractDescriptionText(description ?? '')).valid,
}),
presentation: {
message: messages.spam,
nag: { title: messages.removeSpam, ...commonNagPresentation },
},
},
'project-description-banned-link': {
severity: 'error',
evaluate: (description) => {
const bannedLink = findBannedDescriptionLink(description ?? '')
if (bannedLink) {
return { valid: false, values: { fullUrl: bannedLink } }
} else {
return { valid: true }
}
},
presentation: {
message: messages.bannedLink,
nag: { title: messages.fixDescription, ...commonNagPresentation },
},
},
'long-headers': {
severity: 'error',
evaluate: (description) => {
const { longHeaders } = analyzeHeaderLength(description ?? '')
if (longHeaders.length > 0) {
return { valid: false, values: { count: longHeaders.length } }
} else {
return { valid: true }
}
},
presentation: {
message: messages.longHeaders,
nag: { title: messages.shortenHeaders, ...commonNagPresentation },
},
},
'description-ends-with-header': {
severity: 'error',
evaluate: (description) => ({
valid: !analyzeHeaderStructure(description ?? '').descriptionEndsWithHeader,
}),
presentation: {
message: messages.descriptionEndsWithHeader,
nag: { title: messages.addContentAfterHeader, ...commonNagPresentation },
},
},
'adjacent-headers': {
severity: 'error',
evaluate: (description) => ({
valid: !analyzeHeaderStructure(description ?? '').hasAdjacentSameLevelHeaders,
}),
presentation: {
message: messages.adjacentHeaders,
nag: { title: messages.separateHeaders, ...commonNagPresentation },
},
},
'missing-alt-text': {
severity: 'warning',
evaluate: (description) => ({
valid: !analyzeImageContent(description ?? '').hasEmptyAltText,
}),
presentation: {
message: messages.missingAltText,
nag: { title: messages.addImageAltText, ...commonNagPresentation },
},
},
} satisfies ValidationRuleSet<DescriptionInput>
export function validateProjectDescription(
description: DescriptionInput,
): FieldValidationMessage[] {
return toFieldMessages(evaluateRules(description, projectDescriptionValidationRules))
}
export function getDescriptionNags(context: Pick<ProjectValidationContext, 'projectV3'>): Nag[] {
const matches = evaluateRules(context.projectV3.description, projectDescriptionValidationRules)
return toNags(
projectRequiresEnglishText(context.projectV3)
? matches
: matches.filter(({ code }) => code !== 'project-description-non-english'),
)
}
@@ -1,94 +0,0 @@
import type { Labrinth } from '@modrinth/api-client'
import { defineMessages } from '@modrinth/ui/i18n'
import { formatProjectTypeSentence } from '@modrinth/ui/src/utils/common-messages.ts'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { hasProjectTextHtmlFormatting } from '../text.ts'
import { toFieldMessages } from '../to-field-messages.ts'
import { toNags } from '../to-nags.ts'
import type { FieldValidationMessage, ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
title: {
id: 'nags.check-disclosures.title',
defaultMessage: `Review disclosures`,
},
description: {
id: 'nags.check-disclosures.description',
defaultMessage: `Make sure users are aware of any important details by filling in content disclosures that apply to your {type}.`,
},
removeFormatting: {
id: 'nags.disclosures-special-formatting.title',
defaultMessage: `Fix disclosure formatting`,
},
specialFormatting: {
id: 'nags.disclosures-special-formatting.description',
defaultMessage: `Content disclosures should not contain HTML, since they can only display inline Markdown and plain text.`,
},
})
function getDisclosureText(disclosure: Labrinth.Projects.v3.ProjectDisclosure): string[] {
switch (disclosure.type) {
case 'ai_content':
case 'advertisements':
case 'epilepsy_triggers':
case 'archived':
return disclosure.note ? [disclosure.note] : []
case 'system_interactions':
return disclosure.note ? [disclosure.note] : []
case 'telemetry':
return disclosure.data_collected
case 'derivative_work':
return disclosure.sources.flatMap((source) =>
source.note ? [source.label, source.note] : [source.label],
)
case 'paid_features':
return disclosure.features
}
}
export const projectDisclosureTextValidationRules = {
'disclosures-special-formatting': {
severity: 'error',
evaluate: (disclosures) => ({
valid: !disclosures.some((disclosure) =>
getDisclosureText(disclosure).some(hasProjectTextHtmlFormatting),
),
}),
presentation: {
message: messages.specialFormatting,
nag: { title: messages.removeFormatting, destination: 'disclosures' },
},
},
} satisfies ValidationRuleSet<readonly Labrinth.Projects.v3.ProjectDisclosure[]>
export function validateProjectDisclosures(
disclosures: readonly Labrinth.Projects.v3.ProjectDisclosure[],
): FieldValidationMessage[] {
return toFieldMessages(evaluateRules(disclosures, projectDisclosureTextValidationRules))
}
export const projectDisclosureValidationRules = {
'check-disclosures': {
severity: 'suggestion',
evaluate: (context) => ({
valid: false,
values: { projectType: context.project.project_type },
}),
presentation: {
message: messages.description,
nag: {
title: messages.title,
destination: 'disclosures',
formatValues: (values, formatMessage) => ({
type: formatProjectTypeSentence(formatMessage, String(values.projectType)),
}),
},
},
},
} satisfies ValidationRuleSet<ProjectValidationContext>
export function getDisclosureNags(context: ProjectValidationContext): Nag[] {
return toNags(evaluateRules(context, projectDisclosureValidationRules))
}
@@ -1,166 +0,0 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { evaluateNonStandardText, evaluateProfanity, evaluateSlur } from '../text.ts'
import { toFieldMessages } from '../to-field-messages.ts'
import { toNags } from '../to-nags.ts'
import type { FieldValidationMessage, ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
uploadImage: {
id: 'nags.upload-gallery-image.title',
defaultMessage: `Upload a gallery image`,
},
uploadResourcePackImage: {
id: 'nags.upload-gallery-image.description-resourcepack',
defaultMessage: `At least one gallery image is required to showcase the content of your resource pack, except for audio or localization packs. If this describes your pack, please select the appropriate tag.`,
},
uploadShaderImages: {
id: 'nags.upload-gallery-image.description-shader',
defaultMessage: `At least three gallery images are required to showcase the content of your shader in a variety of situations and conditions.`,
},
uploadImageDescription: {
id: 'nags.upload-gallery-image.description',
defaultMessage: `At least one gallery image is required to showcase the content of your {type}.`,
},
featureImage: {
id: 'nags.feature-gallery-image.title',
defaultMessage: `Feature a gallery image`,
},
featureImageDescription: {
id: 'nags.feature-gallery-image.description',
defaultMessage: `The featured gallery image is often how your project makes its first impression.`,
},
fixText: {
id: 'nags.invalid-gallery-text.title',
defaultMessage: `Modify gallery image text`,
},
editGallery: {
id: 'nags.edit-gallery.title',
defaultMessage: `Edit gallery`,
},
slur: {
id: 'nags.gallery-text-slur.description',
defaultMessage: `Your gallery image titles and descriptions must not contain offensive terms. Detected: “{value}”.`,
},
profanity: {
id: 'nags.gallery-text-profanity.description',
defaultMessage: `Your gallery image titles and descriptions cannot contain excessive profanity. Detected: “{value}”.`,
},
nonStandardText: {
id: 'nags.gallery-text-non-standard.description',
defaultMessage: `Non-standard text characters, such as “Fancy text” or “Zalgo”, are not allowed in gallery image titles or descriptions.`,
},
})
type GalleryTextInput = string | null | undefined
export const projectGalleryTextValidationRules = {
'gallery-text-slur': {
severity: 'error',
evaluate: (text) => evaluateSlur(text ?? ''),
presentation: {
message: messages.slur,
nag: {
title: messages.fixText,
destination: 'gallery',
linkTitle: messages.editGallery,
},
},
},
'gallery-text-profanity': {
severity: 'error',
evaluate: (text) => evaluateProfanity(text ?? ''),
presentation: {
message: messages.profanity,
nag: {
title: messages.fixText,
destination: 'gallery',
linkTitle: messages.editGallery,
},
},
},
'gallery-text-non-standard': {
severity: 'error',
evaluate: (text) => evaluateNonStandardText(text ?? ''),
presentation: {
message: messages.nonStandardText,
nag: {
title: messages.fixText,
destination: 'gallery',
linkTitle: messages.editGallery,
},
},
},
} satisfies ValidationRuleSet<GalleryTextInput>
export const projectGalleryValidationRules = {
'upload-gallery-image': {
severity: 'error',
evaluate: (context) => {
const isShader = context.projectV3.project_types.includes('shader')
if (isShader && context.project.gallery && context.project.gallery.length < 3) {
return { valid: false, message: messages.uploadShaderImages }
}
const isResourcePack = context.projectV3.project_types.includes('resourcepack')
const categories = context.project.categories.concat(
context.project.additional_categories ?? [],
)
if (
isResourcePack &&
context.project.gallery &&
context.project.gallery.length === 0 &&
!categories.includes('audio') &&
!categories.includes('locale')
) {
return { valid: false, message: messages.uploadResourcePackImage }
}
return { valid: true }
},
presentation: {
message: messages.uploadImageDescription,
nag: { title: messages.uploadImage, destination: 'gallery' },
},
},
'feature-gallery-image': {
severity: 'suggestion',
evaluate: (context) => ({
valid:
Boolean(context.projectV3.minecraft_server) ||
Boolean(context.project.gallery?.find((image) => image.featured)),
}),
presentation: {
message: messages.featureImageDescription,
nag: { title: messages.featureImage, destination: 'gallery' },
},
},
} satisfies ValidationRuleSet<ProjectValidationContext>
export function validateProjectGalleryName(name: GalleryTextInput): FieldValidationMessage[] {
return toFieldMessages(evaluateRules(name, projectGalleryTextValidationRules))
}
export function validateProjectGalleryDescription(
description: GalleryTextInput,
): FieldValidationMessage[] {
return toFieldMessages(evaluateRules(description, projectGalleryTextValidationRules))
}
export function getGalleryNags(context: ProjectValidationContext): Nag[] {
const galleryNags = toNags(evaluateRules(context, projectGalleryValidationRules))
const textNags = context.projectV3.gallery.flatMap((item, index) => {
const nameNags = toNags(evaluateRules(item.name, projectGalleryTextValidationRules)).map(
(nag) => ({ ...nag, id: `${nag.id}-${index}-name` }),
)
const descriptionNags = toNags(
evaluateRules(item.description, projectGalleryTextValidationRules),
).map((nag) => ({ ...nag, id: `${nag.id}-${index}-description` }))
return [...nameNags, ...descriptionNags]
})
return [...galleryNags, ...textNags]
}
@@ -1,32 +0,0 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { toNags } from '../to-nags.ts'
import type { ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
title: {
id: 'nags.add-icon.title',
defaultMessage: `Add an icon`,
},
description: {
id: 'nags.add-icon.description',
defaultMessage: `Adding a unique, relevant, and engaging icon makes your project identifiable and helps it stand out.`,
},
})
export const projectIconValidationRules = {
'add-icon': {
severity: 'suggestion',
evaluate: (context) => ({ valid: Boolean(context.project.icon_url) }),
presentation: {
message: messages.description,
nag: { title: messages.title, destination: 'general' },
},
},
} satisfies ValidationRuleSet<ProjectValidationContext>
export function getIconNags(context: ProjectValidationContext): Nag[] {
return toNags(evaluateRules(context, projectIconValidationRules))
}
@@ -1,120 +0,0 @@
import { defineMessages } from '@modrinth/ui/i18n'
import { formatProjectTypeSentence } from '@modrinth/ui/src/utils/common-messages.ts'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { getLinkHostname, isInappropriateLicenseLink } from '../../validators/links/index.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { toNags } from '../to-nags.ts'
import type { ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
selectLicense: {
id: 'nags.select-license.title',
defaultMessage: `Select a license`,
},
selectLicenseDescription: {
id: 'nags.select-license.description',
defaultMessage: `Select the license your {type} is distributed under.`,
},
addDetails: {
id: 'nags.add-license-details.title',
defaultMessage: `Add license details`,
},
addDetailsDescription: {
id: 'nags.add-license-details.description',
defaultMessage: `Add a valid URL and name or SPDX identifier for your custom license.`,
},
invalidUrl: {
id: 'nags.invalid-license-url.title',
defaultMessage: `Add a valid license link`,
},
invalidUrlDefault: {
id: 'nags.invalid-license-url.description.default',
defaultMessage: `License URL is invalid.`,
},
invalidUrlDomain: {
id: 'nags.invalid-license-url.description.domain',
defaultMessage: `Your license URL points to {domain}, which is not appropriate for license information. License URLs should link directly to your license text.`,
},
invalidUrlMalformed: {
id: 'nags.invalid-license-url.description.malformed',
defaultMessage: `Your license URL appears to be malformed. Please provide a valid URL to your license text.`,
},
editLicense: {
id: 'nags.edit-license.title',
defaultMessage: `Edit license`,
},
})
export const projectLicenseValidationRules = {
'select-license': {
severity: 'error',
evaluate: (context) => {
const licenseId = context.project.license.id
const unknown =
licenseId === 'LicenseRef-Unknown' ||
licenseId === 'NOASSERTION' ||
licenseId === 'LicenseRef-NOASSERTION'
return unknown && !context.projectV3.minecraft_server
? { valid: false, values: { projectType: context.project.project_type } }
: { valid: true }
},
presentation: {
message: messages.selectLicenseDescription,
nag: {
title: messages.selectLicense,
destination: 'license',
formatValues: (values, formatMessage) => ({
type: formatProjectTypeSentence(formatMessage, String(values.projectType)),
}),
},
},
},
'add-custom-license-details': {
severity: 'error',
evaluate: (context) => {
const license = context.project.license
const missingDetails =
license.id === 'LicenseRef-' ||
(license.id.startsWith('LicenseRef-') &&
!license.url &&
license.id !== 'LicenseRef-Unknown' &&
license.id !== 'LicenseRef-All-Rights-Reserved')
return { valid: Boolean(context.projectV3.minecraft_server) || !missingDetails }
},
presentation: {
message: messages.addDetailsDescription,
nag: { title: messages.addDetails, destination: 'license' },
},
},
'invalid-license-url': {
severity: 'error',
evaluate: (context) => {
const licenseUrl = context.project.license.url
if (!licenseUrl) return { valid: true }
const domain = getLinkHostname(licenseUrl)
if (domain && isInappropriateLicenseLink(licenseUrl)) {
return {
valid: false,
message: messages.invalidUrlDomain,
values: { domain },
}
}
if (!domain) return { valid: false, message: messages.invalidUrlMalformed }
return { valid: true }
},
presentation: {
message: messages.invalidUrlDefault,
nag: {
title: messages.invalidUrl,
destination: 'license',
linkTitle: messages.editLicense,
},
},
},
} satisfies ValidationRuleSet<ProjectValidationContext>
export function getLicenseNags(context: ProjectValidationContext): Nag[] {
return toNags(evaluateRules(context, projectLicenseValidationRules))
}
@@ -1,229 +0,0 @@
import { defineMessages } from '@modrinth/ui/i18n'
import { formatProjectTypeSentence } from '@modrinth/ui/src/utils/common-messages.ts'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { licenseRequiresSource, notSourceAsDistributed } from '../../utils.ts'
import {
getBlockedProjectExternalLink,
isCommonProjectLink,
isDiscordLink,
} from '../../validators/links/index.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { toNags } from '../to-nags.ts'
import type { ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
addLinks: {
id: 'nags.add-links.title',
defaultMessage: `Add external links`,
},
addServerLinks: {
id: 'nags.add-links-server.title',
defaultMessage: `Add external links`,
},
addLinksDescription: {
id: 'nags.add-links.description',
defaultMessage: `Add any relevant links to external resources, such as source code, an issue tracker, or a permanent Discord invite.`,
},
addServerLinksDescription: {
id: 'nags.add-links-server.description',
defaultMessage: `Add any relevant links to external resources, such as a website, store, or a permanent Discord invite.`,
},
identicalLinks: {
id: 'nags.identical-links.title',
defaultMessage: `Remove identical links`,
},
identicalLinksDescription: {
id: 'nags.identical-links.description',
defaultMessage: `Some of your external links appear to be identical. Each link should be listed only once and with the appropriate link type.`,
},
verifyLinks: {
id: 'nags.verify-external-links.title',
defaultMessage: `Review external links`,
},
verifyLinksDescription: {
id: 'nags.verify-external-links.description',
defaultMessage: `Some of your external links may lead to domains that are inappropriate for that link type.`,
},
moveDiscordInvite: {
id: 'nags.misused-discord-link.title',
defaultMessage: `Move Discord invite`,
},
moveDiscordInviteDescription: {
id: 'nags.misused-discord-link-description',
defaultMessage: `Discord invites can not be used for other link types. Please only put your Discord link in the Discord Invite link field.`,
},
removeBannedLinks: {
id: 'nags.banned-link-usage.title',
defaultMessage: `Remove prohibited links`,
},
removeBannedLinksDescription: {
id: 'nags.banned-link-usage.description',
defaultMessage: `The link “{url}” is not allowed as an external link.`,
},
provideSource: {
id: 'nags.gpl-license-source-required.title',
defaultMessage: `Provide source code`,
},
provideSourceDescription: {
id: 'nags.gpl-license-source-required.description',
defaultMessage: `Your {type}'s license requires source code to be published. Please provide a source code link, add sources files, or change license.`,
},
visitLinks: {
id: 'nags.visit-links-settings.title',
defaultMessage: `Visit links settings`,
},
})
export function findBlockedProjectExternalLink(
context: Pick<ProjectValidationContext, 'project' | 'projectV3'>,
) {
const urls = [
context.project.source_url,
context.project.issues_url,
context.project.wiki_url,
context.project.discord_url,
context.project.license.url,
...(context.project.donation_urls ?? []).map(({ url }) => url),
...Object.values(context.projectV3.link_urls ?? {}).map(({ url }) => url),
]
for (const url of urls) {
if (!url) continue
const blockedLink = getBlockedProjectExternalLink(url)
if (blockedLink) return blockedLink
}
return null
}
export const projectLinksValidationRules = {
'add-links': {
severity: 'suggestion',
evaluate: (context) => ({
valid:
Boolean(context.projectV3.minecraft_server) ||
Object.keys(context.projectV3.link_urls ?? {}).length > 0,
}),
presentation: {
message: messages.addLinksDescription,
nag: { title: messages.addLinks, destination: 'links' },
},
},
'add-links-server': {
severity: 'suggestion',
evaluate: (context) => ({
valid:
!context.projectV3.minecraft_server ||
Object.keys(context.projectV3.link_urls ?? {}).length > 0,
}),
presentation: {
message: messages.addServerLinksDescription,
nag: { title: messages.addServerLinks, destination: 'links' },
},
},
'identical-links': {
severity: 'error',
evaluate: (context) => {
const links = Object.values(context.projectV3.link_urls ?? {}).map(({ url }) => url)
return { valid: new Set(links).size === links.length }
},
presentation: {
message: messages.identicalLinksDescription,
nag: { title: messages.identicalLinks, destination: 'links' },
},
},
'verify-external-links': {
severity: 'warning',
evaluate: (context) => {
const sourceUrl = context.project.source_url
const issuesUrl = context.project.issues_url
const discordUrl = context.project.discord_url
return {
valid: !(
(sourceUrl && !isCommonProjectLink(sourceUrl, 'source')) ||
(issuesUrl && !isCommonProjectLink(issuesUrl, 'issues')) ||
(discordUrl && !isCommonProjectLink(discordUrl, 'discord'))
),
}
},
presentation: {
message: messages.verifyLinksDescription,
nag: {
title: messages.verifyLinks,
destination: 'links',
linkTitle: messages.visitLinks,
},
},
},
'misused-discord-link': {
severity: 'error',
evaluate: (context) => ({
valid: !(
isDiscordLink(context.project.source_url) ||
isDiscordLink(context.project.issues_url) ||
isDiscordLink(context.project.wiki_url) ||
isDiscordLink(context.projectV3.link_urls?.site?.url) ||
isDiscordLink(context.projectV3.link_urls?.store?.url)
),
}),
presentation: {
message: messages.moveDiscordInviteDescription,
nag: {
title: messages.moveDiscordInvite,
destination: 'links',
linkTitle: messages.visitLinks,
},
},
},
'banned-link-usage': {
severity: 'error',
evaluate: (context) => {
const blockedLink = findBlockedProjectExternalLink(context)
return blockedLink ? { valid: false, values: { url: blockedLink.url } } : { valid: true }
},
presentation: {
message: messages.removeBannedLinksDescription,
nag: {
title: messages.removeBannedLinks,
destination: 'links',
linkTitle: messages.visitLinks,
},
},
},
'gpl-license-source-required': {
severity: 'error',
evaluate: (context) => {
if (context.projectV3.project_types.includes('datapack')) return { valid: true }
const hasSourceUrl = Boolean(context.project.source_url)
const everyVersionHasAdditionalFiles = context.versions.every(
(version) => version.files.length >= 2,
)
const requiresSource =
licenseRequiresSource(context.projectV3.license.id) &&
notSourceAsDistributed(context.projectV3.project_types) &&
!hasSourceUrl &&
!everyVersionHasAdditionalFiles
return requiresSource
? { valid: false, values: { projectType: context.project.project_type } }
: { valid: true }
},
presentation: {
message: messages.provideSourceDescription,
nag: {
title: messages.provideSource,
destination: 'links',
linkTitle: messages.visitLinks,
formatValues: (values, formatMessage) => ({
type: formatProjectTypeSentence(formatMessage, String(values.projectType)),
}),
},
},
},
} satisfies ValidationRuleSet<ProjectValidationContext>
export function getLinksNags(context: ProjectValidationContext): Nag[] {
return toNags(evaluateRules(context, projectLinksValidationRules))
}
@@ -1,34 +0,0 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { toNags } from '../to-nags.ts'
import type { ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
title: {
id: 'nags.moderator-feedback.title',
defaultMessage: `Review feedback`,
},
description: {
id: 'nags.moderator-feedback.description',
defaultMessage: `Review and address all concerns from the moderation team before resubmitting.`,
},
})
export const projectModerationValidationRules = {
'moderator-feedback': {
severity: 'warning',
evaluate: (context) => ({
valid: !context.tags.rejectedStatuses.includes(context.project.status),
}),
presentation: {
message: messages.description,
nag: { title: messages.title, destination: 'moderation' },
},
},
} satisfies ValidationRuleSet<ProjectValidationContext>
export function getModerationNags(context: ProjectValidationContext): Nag[] {
return toNags(evaluateRules(context, projectModerationValidationRules))
}
@@ -1,146 +0,0 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, NagContext } from '../../types/nags.ts'
import { validateNonStandardText } from '../../validators/non-standard-text/index.ts'
import { validateProfanity } from '../../validators/profanity/index.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { toFieldMessages } from '../to-field-messages.ts'
import { toNags } from '../to-nags.ts'
import type { FieldValidationMessage, ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
fixName: {
id: 'nags.invalid-project-name.title',
defaultMessage: `Modify project name`,
},
fixVersion: {
id: 'nags.project-name-version.title',
defaultMessage: `Modify project name`,
},
avoidBrandInfringement: {
id: 'nags.minecraft-title-clause.title',
defaultMessage: `Avoid brand infringement`,
},
editName: {
id: 'nags.edit-title.title',
defaultMessage: `Edit name`,
},
slur: {
id: 'nags.project-name-slur.description',
defaultMessage: `Your project's name must not contain offensive terms. Detected: “{value}”.`,
},
profanity: {
id: 'nags.project-name-profanity.description',
defaultMessage: `Your project's name cannot contain profanity. Detected: “{value}”.`,
},
nonStandardText: {
id: 'nags.project-name-non-standard-text.description',
defaultMessage: `Non-standard text characters, such as “Fancy text” or “Zalgo”, are not allowed in the project name.`,
},
versionNumber: {
id: 'project.text-validation.title-version-number',
defaultMessage: `Project names should not include version numbers.`,
},
minecraftBranding: {
id: 'nags.minecraft-title-clause.description',
defaultMessage: `Projects must not use Minecraft's branding or include "Minecraft" as a significant part of the name.`,
},
})
export const projectNameValidationRules = {
'project-name-slur': {
severity: 'error',
evaluate: (projectName) => {
const match = validateProfanity(projectName).matches.find((match) => match.kind === 'slur')
if (match) {
return { valid: false, values: { value: match.rawText } }
} else {
return { valid: true }
}
},
presentation: {
message: messages.slur,
nag: {
title: messages.fixName,
destination: 'general',
linkTitle: messages.editName,
},
},
},
'project-name-profanity': {
severity: 'error',
evaluate: (projectName) => {
const match = validateProfanity(projectName).matches.find(
(match) => match.kind === 'profanity',
)
if (match) {
return { valid: false, values: { value: match.rawText } }
} else {
return { valid: true }
}
},
presentation: {
message: messages.profanity,
nag: {
title: messages.fixName,
destination: 'general',
linkTitle: messages.editName,
},
},
},
'project-name-non-standard-text': {
severity: 'error',
evaluate: (projectName) => ({ valid: validateNonStandardText(projectName).valid }),
presentation: {
message: messages.nonStandardText,
nag: {
title: messages.fixName,
destination: 'general',
linkTitle: messages.editName,
},
},
},
'project-name-version': {
severity: 'error',
evaluate: (projectName) => {
const normalizedName = projectName.normalize('NFC').toLowerCase()
const isPortOrFork = normalizedName.includes('port') || normalizedName.includes('fork')
const includesVersionNumber = /\d+(?:\.\d+)+/.test(normalizedName)
return { valid: !includesVersionNumber || isPortOrFork }
},
presentation: {
message: messages.versionNumber,
nag: {
title: messages.fixVersion,
destination: 'general',
linkTitle: messages.editName,
},
},
},
'minecraft-title-clause': {
severity: 'error',
evaluate: (projectName) => {
const normalizedName = projectName.normalize('NFC').toLowerCase()
const words = normalizedName.split(/\s+/).filter(Boolean)
return {
valid: !(normalizedName.includes('minecraft') && words.length <= 3),
}
},
presentation: {
message: messages.minecraftBranding,
nag: {
title: messages.avoidBrandInfringement,
destination: 'general',
linkTitle: messages.editName,
},
},
},
} satisfies ValidationRuleSet<string>
export function validateProjectNameField(name: string): FieldValidationMessage[] {
return toFieldMessages(evaluateRules(name, projectNameValidationRules))
}
export function getNameNags(context: Pick<NagContext, 'projectV3'>): Nag[] {
return toNags(evaluateRules(context.projectV3.name, projectNameValidationRules))
}
@@ -1,36 +0,0 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { toNags } from '../to-nags.ts'
import type { ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
title: {
id: 'nags.review-permissions.title',
defaultMessage: `Check content permission`,
},
description: {
id: 'nags.review-permissions.description',
defaultMessage: `Make sure you have provided proof of your permission to distribute any external content in your Modpack.`,
},
})
export const projectPermissionsValidationRules = {
'review-permissions': {
severity: 'error',
evaluate: (context) => ({
valid: !context.versions.some(
(version) => (version.files_missing_attribution?.length ?? 0) >= 1,
),
}),
presentation: {
message: messages.description,
nag: { title: messages.title, destination: 'permissions' },
},
},
} satisfies ValidationRuleSet<ProjectValidationContext>
export function getPermissionsNags(context: ProjectValidationContext): Nag[] {
return toNags(evaluateRules(context, projectPermissionsValidationRules))
}
@@ -1,134 +0,0 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { toNags } from '../to-nags.ts'
import type { ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
selectCountry: {
id: 'nags.select-country.title',
defaultMessage: `Select a region`,
},
selectCountryDescription: {
id: 'nags.select-country.description',
defaultMessage: `Let players know what region your server is located in.`,
},
selectAccurateLanguages: {
id: 'nags.all-languages.title',
defaultMessage: `Select accurate languages`,
},
allLanguages: {
id: 'nags.all-languages.description',
defaultMessage: `You've selected all available language options. Please list only the languages your server actively supports.`,
},
addJavaAddress: {
id: 'nags.add-java-address.title',
defaultMessage: `Add a Java address`,
},
addJavaAddressDescription: {
id: 'nags.add-java-address.description',
defaultMessage: `Add the IP address and port Java Edition players can use to join your server.`,
},
selectCompatibility: {
id: 'nags.select-compatibility.title',
defaultMessage: `Select compatibility`,
},
selectCompatibilityDescription: {
id: 'nags.select-compatibility.description',
defaultMessage: `Select what versions your server supports, choose a Modpack, or upload your own.`,
},
tooManyLanguages: {
id: 'nags.too-many-languages.title',
defaultMessage: `Select accurate languages`,
},
tooManyLanguagesDescription: {
id: 'nags.too-many-languages.description',
defaultMessage: `You've selected {languageCount, plural, one {# language} other {# languages}}. Please list only the languages your server actively supports.`,
},
selectLanguage: {
id: 'nags.select-language.title',
defaultMessage: `Select a language`,
},
selectLanguageDescription: {
id: 'nags.select-language.description',
defaultMessage: `List the language or languages supported by your server.`,
},
})
export const MAX_LANGUAGE_COUNT = 10
export const projectServerSettingsValidationRules = {
'select-country': {
severity: 'error',
evaluate: (context) => ({
valid:
!context.projectV3.minecraft_server || Boolean(context.projectV3.minecraft_server.region),
}),
presentation: {
message: messages.selectCountryDescription,
nag: { title: messages.selectCountry, destination: 'server' },
},
},
'all-languages': {
severity: 'error',
evaluate: () => ({ valid: true }),
presentation: {
message: messages.allLanguages,
nag: { title: messages.selectAccurateLanguages, destination: 'server' },
},
},
'add-java-address': {
severity: 'error',
evaluate: (context) => ({
valid:
!context.projectV3.minecraft_server ||
Boolean(context.projectV3.minecraft_java_server?.address),
}),
presentation: {
message: messages.addJavaAddressDescription,
nag: { title: messages.addJavaAddress, destination: 'server' },
},
},
'select-compatibility': {
severity: 'error',
evaluate: (context) => ({
valid:
context.projectV3.minecraft_java_server?.content?.kind !== 'vanilla' ||
Boolean(context.projectV3.minecraft_java_server.content.recommended_game_version),
}),
presentation: {
message: messages.selectCompatibilityDescription,
nag: { title: messages.selectCompatibility, destination: 'server' },
},
},
'too-many-languages': {
severity: 'warning',
evaluate: (context) => {
const languageCount = context.projectV3.minecraft_server?.languages?.length ?? 0
return languageCount > MAX_LANGUAGE_COUNT
? { valid: false, values: { languageCount } }
: { valid: true }
},
presentation: {
message: messages.tooManyLanguagesDescription,
nag: { title: messages.tooManyLanguages, destination: 'server' },
},
},
'select-language': {
severity: 'suggestion',
evaluate: (context) => ({
valid:
!context.projectV3.minecraft_server ||
(context.projectV3.minecraft_server.languages?.length ?? 0) > 0,
}),
presentation: {
message: messages.selectLanguageDescription,
nag: { title: messages.selectLanguage, destination: 'server' },
},
},
} satisfies ValidationRuleSet<ProjectValidationContext>
export function getServerSettingsNags(context: ProjectValidationContext): Nag[] {
return toNags(evaluateRules(context, projectServerSettingsValidationRules))
}
@@ -1,274 +0,0 @@
import { defineMessages } from '@modrinth/ui/i18n'
import LinkifyIt from 'linkify-it'
import tlds from 'tlds' with { type: 'json' }
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { validateSpam } from '../../validators/spam/index.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import {
evaluateEnglishSummaryText,
evaluateNonStandardText,
evaluateProfanity,
evaluateSlur,
hasProjectTextFormatting,
normalizeProjectFieldText,
projectRequiresEnglishText,
} from '../text.ts'
import { toFieldMessages } from '../to-field-messages.ts'
import { toNags } from '../to-nags.ts'
import type { FieldValidationMessage, ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
fixSummary: {
id: 'nags.invalid-project-summary.title',
defaultMessage: `Modify the summary`,
},
reviewSummary: {
id: 'nags.project-summary-content.title',
defaultMessage: `Review the summary`,
},
expandSummary: {
id: 'nags.summary-too-short.title',
defaultMessage: `Expand the summary`,
},
removeSpam: {
id: 'nags.project-summary-spam.title',
defaultMessage: `Remove summary spam`,
},
cleanUpSummary: {
id: 'nags.summary-special-formatting.title',
defaultMessage: `Fix summary formatting`,
},
removeSummaryLinks: {
id: 'nags.project-summary-links.title',
defaultMessage: `Remove summary links`,
},
editSummary: {
id: 'nags.edit-summary.title',
defaultMessage: `Edit summary`,
},
slur: {
id: 'nags.project-summary-slur.description',
defaultMessage: `Your project's summary must not contain offensive terms. Detected: “{value}”.`,
},
profanity: {
id: 'nags.project-summary-profanity.description',
defaultMessage: `Your project's summary cannot contain profanity. Detected: “{value}”.`,
},
nonStandardText: {
id: 'nags.project-summary-non-standard-text.description',
defaultMessage: `Non-standard text characters, such as “Fancy text” or “Zalgo”, are not allowed in the summary.`,
},
nonEnglish: {
id: 'nags.project-summary-non-english.description',
defaultMessage: `Your project's summary must be written in English or include an English translation.`,
},
matchesName: {
id: 'project.text-validation.summary-matches-title',
defaultMessage: `Your project's summary should provide unique information and not repeat the project's name.`,
},
tooShort: {
id: 'project.text-validation.summary-too-short',
defaultMessage: `Your summary is too brief. Add a sentence or two that describes your project.`,
},
spam: {
id: 'nags.project-summary-spam.description',
defaultMessage: `Repeated characters, words, or phrases should be used to pad your project's summary.`,
},
specialFormatting: {
id: 'nags.summary-special-formatting.description',
defaultMessage: `Your summary should not contain Markdown or HTML, since it can only display plain text.`,
},
links: {
id: 'nags.project-summary-links.description',
defaultMessage: `Links, URLs, and IPs should not be placed in the summary. Detected: "{value}"`,
},
})
export const MIN_SUMMARY_CHARS = 25
export const MAX_SUMMARY_NAME_SIMILARITY = 0.8
export interface ProjectSummaryValidationInput {
summary: string | null | undefined
name: string | null | undefined
}
const summaryLinkify = new LinkifyIt({
fuzzyEmail: false,
fuzzyIP: true,
fuzzyLink: true,
}).tlds(tlds)
function findProjectSummaryLinkOrIp(summary: string): string | null {
return summaryLinkify.match(summary)?.[0].raw ?? null
}
function getLevenshteinDistance(left: string[], right: string[]) {
if (left.length > right.length) return getLevenshteinDistance(right, left)
let previousRow = Array.from({ length: left.length + 1 }, (_, index) => index)
for (let rightIndex = 0; rightIndex < right.length; rightIndex++) {
const currentRow = [rightIndex + 1]
for (let leftIndex = 0; leftIndex < left.length; leftIndex++) {
currentRow.push(
Math.min(
currentRow[leftIndex] + 1,
previousRow[leftIndex + 1] + 1,
previousRow[leftIndex] + (left[leftIndex] === right[rightIndex] ? 0 : 1),
),
)
}
previousRow = currentRow
}
return previousRow[left.length]
}
function normalizeProjectFieldTextForSimilarity(value: string) {
return Array.from(normalizeProjectFieldText(value).toLocaleLowerCase('en-US').replace(/\s+/g, ''))
}
export function getProjectSummaryNameSimilarity(summary: string, name: string) {
const normalizedSummary = normalizeProjectFieldTextForSimilarity(summary)
const normalizedName = normalizeProjectFieldTextForSimilarity(name)
const longestLength = Math.max(normalizedSummary.length, normalizedName.length)
if (longestLength === 0) return 0
return 1 - getLevenshteinDistance(normalizedSummary, normalizedName) / longestLength
}
export function hasProjectSummaryFormatting(summary: string) {
return hasProjectTextFormatting(summary)
}
const commonNagPresentation = {
destination: 'general',
linkTitle: messages.editSummary,
} as const
export const projectSummaryValidationRules = {
'project-summary-slur': {
severity: 'error',
evaluate: ({ summary }) => evaluateSlur(summary ?? ''),
presentation: {
message: messages.slur,
nag: { title: messages.fixSummary, ...commonNagPresentation },
},
},
'project-summary-profanity': {
severity: 'error',
evaluate: ({ summary }) => evaluateProfanity(summary ?? ''),
presentation: {
message: messages.profanity,
nag: { title: messages.fixSummary, ...commonNagPresentation },
},
},
'project-summary-non-standard-text': {
severity: 'error',
evaluate: ({ summary }) => evaluateNonStandardText(summary ?? ''),
presentation: {
message: messages.nonStandardText,
nag: { title: messages.fixSummary, ...commonNagPresentation },
},
},
'project-summary-non-english': {
severity: 'error',
evaluate: ({ summary }) => {
const normalized = normalizeProjectFieldText(summary ?? '')
if (
!normalized ||
normalized.length < MIN_SUMMARY_CHARS ||
findProjectSummaryLinkOrIp(normalized) !== null ||
!validateSpam(normalized).valid
) {
return { valid: true }
}
return evaluateEnglishSummaryText(normalized)
},
presentation: {
message: messages.nonEnglish,
nag: { title: messages.fixSummary, ...commonNagPresentation },
},
},
'project-summary-matches-title': {
severity: 'error',
evaluate: ({ summary, name }) => ({
valid:
!summary ||
findProjectSummaryLinkOrIp(summary) !== null ||
!name ||
getProjectSummaryNameSimilarity(summary, name) < MAX_SUMMARY_NAME_SIMILARITY,
}),
presentation: {
message: messages.matchesName,
nag: { title: messages.reviewSummary, ...commonNagPresentation },
},
},
'summary-too-short': {
severity: 'error',
evaluate: ({ summary }) => {
if (!summary || findProjectSummaryLinkOrIp(summary) !== null) return { valid: true }
const length = normalizeProjectFieldText(summary).length
return length < MIN_SUMMARY_CHARS
? { valid: false, values: { length, minChars: MIN_SUMMARY_CHARS } }
: { valid: true }
},
presentation: {
message: messages.tooShort,
nag: { title: messages.expandSummary, ...commonNagPresentation },
},
},
'project-summary-spam': {
severity: 'error',
evaluate: ({ summary }) => ({
valid: validateSpam(normalizeProjectFieldText(summary ?? '')).valid,
}),
presentation: {
message: messages.spam,
nag: { title: messages.removeSpam, ...commonNagPresentation },
},
},
'summary-special-formatting': {
severity: 'error',
evaluate: ({ summary }) => ({
valid: !summary || !hasProjectSummaryFormatting(summary),
}),
presentation: {
message: messages.specialFormatting,
nag: { title: messages.cleanUpSummary, ...commonNagPresentation },
},
},
'project-summary-links': {
severity: 'error',
evaluate: ({ summary }) => {
const match = summary ? findProjectSummaryLinkOrIp(summary) : null
return match ? { valid: false, values: { value: match } } : { valid: true }
},
presentation: {
message: messages.links,
nag: { title: messages.removeSummaryLinks, ...commonNagPresentation },
},
},
} satisfies ValidationRuleSet<ProjectSummaryValidationInput>
export function validateProjectSummary(
input: ProjectSummaryValidationInput,
): FieldValidationMessage[] {
return toFieldMessages(evaluateRules(input, projectSummaryValidationRules))
}
export function getSummaryNags(context: Pick<ProjectValidationContext, 'projectV3'>): Nag[] {
const matches = evaluateRules(
{ summary: context.projectV3.summary, name: context.projectV3.name },
projectSummaryValidationRules,
)
return toNags(
projectRequiresEnglishText(context.projectV3)
? matches
: matches.filter(({ code }) => code !== 'project-summary-non-english'),
)
}
@@ -1,184 +0,0 @@
import type { Labrinth } from '@modrinth/api-client'
import { defineMessages } from '@modrinth/ui/i18n'
import { formatCategory } from '@modrinth/ui/src/utils/tag-messages.ts'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { toNags } from '../to-nags.ts'
import type { ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
selectTags: {
id: 'nags.select-tags.title',
defaultMessage: `Select tags`,
},
selectTagsDescription: {
id: 'nags.select-tags.description',
defaultMessage:
'Select the tags that correctly apply to your project to help the right users find it.',
},
selectAccurateTags: {
id: 'nags.too-many-tags.title',
defaultMessage: `Select accurate tags`,
},
selectAccurateServerTags: {
id: 'nags.too-many-tags-server.title',
defaultMessage: `Select accurate tags`,
},
selectAllTags: {
id: 'nags.all-tags-selected.title',
defaultMessage: `Select accurate tags`,
},
tooManyTags: {
id: 'nags.too-many-tags.description',
defaultMessage: `You've selected {tagCount, plural, one {# tag} other {# tags}}. Please reduce to {maxTagCount} or fewer to ensure your project appears in relevant search results.`,
},
tooManyServerTags: {
id: 'nags.too-many-tags-server.description',
defaultMessage: `You've selected {tagCount, plural, one {# tag} other {# tags}}. Please reduce to {maxTagCount} or fewer to ensure your project appears in relevant search results.`,
},
selectResolution: {
id: 'nags.multiple-resolution-tags.title',
defaultMessage: `Select correct resolution`,
},
multipleResolutionTags: {
id: 'nags.multiple-resolution-tags.description',
defaultMessage: `You've selected {count, plural, one {# resolution tag} other {# resolution tags}} ({tags}). Resource packs should typically only have the tag that matches the primary resolution.`,
},
allTagsSelected: {
id: 'nags.all-tags-selected.description',
defaultMessage: `You've selected all {totalAvailableTags, plural, one {# available tag} other {# available tags}}. Tags should be used to help users find relevant projects. Please only select relevant select.`,
},
editTags: {
id: 'nags.edit-tags.title',
defaultMessage: `Edit tags`,
},
})
export const allResolutionTags = ['8x-', '16x', '32x', '48x', '64x', '128x', '256x', '512x+']
export const MAX_TAG_COUNT = 8
export const MAX_TAG_COUNT_SERVER = 18
function getCategories(
project: Labrinth.Projects.v2.Project & { actualProjectType: string },
tags: ProjectValidationContext['tags'],
) {
return (
tags.categories?.filter((category) => category.project_type === project.actualProjectType) ?? []
)
}
function getSelectedTagCount(context: ProjectValidationContext) {
return context.project.categories.length + (context.project.additional_categories?.length ?? 0)
}
function getResolutionTags(context: ProjectValidationContext) {
return context.project.categories
.concat(context.project.additional_categories ?? [])
.filter((tag) => allResolutionTags.includes(tag))
.toSorted((a, b) => allResolutionTags.indexOf(a) - allResolutionTags.indexOf(b))
}
export const projectTagsValidationRules = {
'select-tags': {
severity: 'suggestion',
evaluate: (context) => ({
valid: context.project.versions.length === 0 || context.project.categories.length > 0,
}),
presentation: {
message: messages.selectTagsDescription,
nag: { title: messages.selectTags, destination: 'tags' },
},
},
'too-many-tags': {
severity: 'warning',
evaluate: (context) => {
const tagCount = getSelectedTagCount(context)
const tooMany =
!context.projectV3.minecraft_java_server &&
!context.projectV3.minecraft_server &&
tagCount > MAX_TAG_COUNT
return tooMany
? { valid: false, values: { tagCount, maxTagCount: MAX_TAG_COUNT } }
: { valid: true }
},
presentation: {
message: messages.tooManyTags,
nag: {
title: messages.selectAccurateTags,
destination: 'tags',
linkTitle: messages.editTags,
},
},
},
'too-many-tags-server': {
severity: 'error',
evaluate: (context) => {
const tagCount = getSelectedTagCount(context)
return context.projectV3.minecraft_server && tagCount > MAX_TAG_COUNT_SERVER
? { valid: false, values: { tagCount, maxTagCount: MAX_TAG_COUNT_SERVER } }
: { valid: true }
},
presentation: {
message: messages.tooManyServerTags,
nag: {
title: messages.selectAccurateServerTags,
destination: 'tags',
linkTitle: messages.editTags,
},
},
},
'multiple-resolution-tags': {
severity: 'warning',
evaluate: (context) => {
const resolutionTags = getResolutionTags(context)
return context.project.project_type === 'resourcepack' && resolutionTags.length > 1
? {
valid: false,
values: { count: resolutionTags.length, tags: resolutionTags.join('|') },
}
: { valid: true }
},
presentation: {
message: messages.multipleResolutionTags,
nag: {
title: messages.selectResolution,
destination: 'tags',
linkTitle: messages.editTags,
formatValues: (values, formatMessage) => ({
count: values.count,
tags: String(values.tags)
.split('|')
.map((tag) => formatCategory(formatMessage, tag))
.join(', '),
}),
},
},
},
'all-tags-selected': {
severity: 'error',
evaluate: (context) => {
const categories = getCategories(
context.project as Labrinth.Projects.v2.Project & { actualProjectType: string },
context.tags,
)
const totalAvailableTags = categories.length
const allSelected =
getSelectedTagCount(context) === totalAvailableTags &&
context.project.project_type !== 'project'
return allSelected ? { valid: false, values: { totalAvailableTags } } : { valid: true }
},
presentation: {
message: messages.allTagsSelected,
nag: {
title: messages.selectAllTags,
destination: 'tags',
linkTitle: messages.editTags,
},
},
},
} satisfies ValidationRuleSet<ProjectValidationContext>
export function getTagsNags(context: ProjectValidationContext): Nag[] {
return toNags(evaluateRules(context, projectTagsValidationRules))
}
@@ -1,61 +0,0 @@
import { defineMessages } from '@modrinth/ui/i18n'
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import { toNags } from '../to-nags.ts'
import type { ValidationRuleSet } from '../types.ts'
const messages = defineMessages({
title: {
id: 'nags.upload-version.title',
defaultMessage: `Upload a version`,
},
description: {
id: 'nags.upload-version.description',
defaultMessage: `At least one version is required for a project to be submitted for review.`,
},
selectEnvironmentTitle: {
id: 'nags.select-environment.title',
defaultMessage: `Select an environment`,
},
selectEnvironmentDescription: {
id: 'nags.select-environment.description',
defaultMessage: `Specify the environment where your project can run.`,
},
})
export const projectVersionValidationRules = {
'upload-version': {
severity: 'error',
evaluate: (context) => ({
valid: context.projectV3.versions.length > 0 || Boolean(context.projectV3.minecraft_server),
}),
presentation: {
message: messages.description,
nag: { title: messages.title, destination: 'versions' },
},
},
'select-environment': {
severity: 'error',
evaluate: (context) => {
const requiresEnvironment = context.projectV3.project_types.some((projectType) =>
['mod', 'modpack'].includes(projectType),
)
const environment = context.projectV3.environment
return {
valid:
!requiresEnvironment ||
(Boolean(environment?.length) && !environment?.includes('unknown')),
}
},
presentation: {
message: messages.selectEnvironmentDescription,
nag: { title: messages.selectEnvironmentTitle, destination: 'versions' },
},
},
} satisfies ValidationRuleSet<ProjectValidationContext>
export function getVersionNags(context: ProjectValidationContext): Nag[] {
return toNags(evaluateRules(context, projectVersionValidationRules))
}
@@ -1,573 +0,0 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { Labrinth } from '@modrinth/api-client'
import type { ProjectValidationContext } from '../types/nags.ts'
import { evaluateRules } from './evaluate-rules.ts'
import {
analyzeHeaderLength,
analyzeHeaderStructure,
analyzeImageContent,
BANNED_DESCRIPTION_LINK_DOMAINS,
countText,
extractDescriptionLinks,
extractRenderedHeaders,
findBannedDescriptionLink,
MIN_DESCRIPTION_CHARS,
validateProjectDescription,
} from './rules/description.ts'
import { validateProjectDisclosures } from './rules/disclosures.ts'
import { validateProjectGalleryDescription, validateProjectGalleryName } from './rules/gallery.ts'
import { projectNameValidationRules, validateProjectNameField } from './rules/name.ts'
import {
getProjectSummaryNameSimilarity,
hasProjectSummaryFormatting,
validateProjectSummary,
} from './rules/summary.ts'
import { projectVersionValidationRules } from './rules/versions.ts'
import { toFieldMessages } from './to-field-messages.ts'
import { toNags } from './to-nags.ts'
import type { ValidationRuleSet } from './types.ts'
test('evaluates matching rules in definition order and converts them to field messages', () => {
const rules = {
'without-values': {
severity: 'error',
evaluate: () => ({ valid: false }),
presentation: {
message: { id: 'without-values' },
nag: { title: { id: 'without-values-title' }, destination: 'general' },
},
},
'with-values': {
severity: 'warning',
evaluate: (value) => ({ valid: false, values: { value } }),
presentation: {
message: { id: 'with-values' },
nag: { title: { id: 'with-values-title' }, destination: 'general' },
},
},
valid: {
severity: 'error',
evaluate: () => ({ valid: true }),
presentation: {
message: { id: 'valid' },
nag: { title: { id: 'valid-title' }, destination: 'general' },
},
},
} satisfies ValidationRuleSet<string>
const matches = evaluateRules('detected', rules)
assert.deepEqual(
matches.map(({ code }) => code),
['without-values', 'with-values'],
)
assert.deepEqual(toFieldMessages(matches), [
{
code: 'without-values',
severity: 'error',
message: { id: 'without-values' },
values: undefined,
},
{
code: 'with-values',
severity: 'warning',
message: { id: 'with-values' },
values: { value: 'detected' },
},
])
})
test('allows clean project names and versioned ports', () => {
assert.deepEqual(evaluateRules('Sodium Extras', projectNameValidationRules), [])
for (const name of [
'Sodium 1.20 Port',
'Port Sodium 1.20',
'Sodium Fork Edition 1.20',
'1.20 Sodium Fork',
'Sodium 1.20 Forked',
'Sodium 1.20 Teleport',
'Sodium 1.20 Port:',
]) {
assert.equal(
evaluateRules(name, projectNameValidationRules).some(
({ code }) => code === 'project-name-version',
),
false,
name,
)
}
assert.equal(
evaluateRules('Sodium Edition 1.20', projectNameValidationRules).some(
({ code }) => code === 'project-name-version',
),
true,
)
})
test('collects every matching project name rule', () => {
const matches = evaluateRules('Minecraft 1.20 fuck', projectNameValidationRules)
assert.deepEqual(
matches.map(({ code }) => code),
['project-name-profanity', 'project-name-version', 'minecraft-title-clause'],
)
assert.deepEqual(matches[0]?.values, { value: 'fuck' })
assert.equal(matches[1]?.rule.severity, 'error')
assert.equal(matches[2]?.rule.severity, 'error')
})
test('derives project name field presentation from the matching rule', () => {
assert.deepEqual(validateProjectNameField('Minecraft'), [
{
code: 'minecraft-title-clause',
severity: 'error',
message: {
id: 'nags.minecraft-title-clause.description',
defaultMessage:
'Projects must not use Minecraft\'s branding or include "Minecraft" as a significant part of the name.',
},
values: undefined,
},
])
})
test('derives project name nags from the same matching rule', () => {
const [nag] = toNags(evaluateRules('Minecraft', projectNameValidationRules))
assert.equal(nag?.id, 'minecraft-title-clause')
assert.equal(nag?.status, 'required')
assert.equal(nag?.title.id, 'nags.minecraft-title-clause.title')
assert.equal(nag?.link?.path, 'settings')
assert.equal(nag?.link?.title.id, 'nags.edit-title.title')
})
test('requires known environments for mods and modpacks', () => {
const validateEnvironment = (
projectTypes: ProjectValidationContext['projectV3']['project_types'],
environment?: ProjectValidationContext['projectV3']['environment'],
) =>
projectVersionValidationRules['select-environment'].evaluate({
projectV3: { project_types: projectTypes, environment },
} as ProjectValidationContext).valid
for (const projectType of ['mod', 'modpack'] as const) {
assert.equal(validateEnvironment([projectType]), false)
assert.equal(validateEnvironment([projectType], []), false)
assert.equal(validateEnvironment([projectType], ['unknown']), false)
assert.equal(validateEnvironment([projectType], ['client_and_server', 'unknown']), false)
assert.equal(validateEnvironment([projectType], ['client_and_server']), true)
}
assert.equal(validateEnvironment(['resourcepack']), true)
})
test('validates summary content from one rule set', () => {
assert.equal(getProjectSummaryNameSimilarity(' Caf\u00e9 ', 'Cafe\u0301'), 1)
assert.deepEqual(
validateProjectSummary({ summary: '# Short summary', name: 'Project title' }).map(
({ code, severity }) => ({ code, severity }),
),
[
{ code: 'summary-too-short', severity: 'error' },
{ code: 'summary-special-formatting', severity: 'error' },
],
)
assert.deepEqual(
validateProjectSummary({
summary: 'A detailed summary of this excellent project',
name: 'Project title',
}),
[],
)
})
test('rejects project summaries that are too similar to the project name', () => {
for (const summary of ['My Project', 'myproject', 'My Projec', 'My Projects']) {
assert.equal(
validateProjectSummary({ summary, name: 'My Project' }).some(
({ code }) => code === 'project-summary-matches-title',
),
true,
summary,
)
}
assert.equal(
validateProjectSummary({
summary: 'A detailed summary of what My Project provides',
name: 'My Project',
}).some(({ code }) => code === 'project-summary-matches-title'),
false,
)
})
test('warns when a project summary is mostly non-English', () => {
assert.deepEqual(
validateProjectSummary({
summary:
'これは新しい洞窟と構造物を追加し、すべてのプレイヤーの世界生成を改善するプロジェクトです。',
name: 'Project title',
}).map(({ code, severity }) => ({ code, severity })),
[{ code: 'project-summary-non-english', severity: 'warning' }],
)
})
test('rejects repeated summary padding', () => {
assert.deepEqual(
validateProjectSummary({
summary: 'Useful project! '.repeat(3),
name: 'Project title',
}).map(({ code }) => code),
['project-summary-spam'],
)
})
test('detects Markdown and HTML formatting in project summaries', () => {
for (const summary of [
'Unknown <span>🩸</span>Unknown is a dark and unsettling horror-survival mod.',
'<custom-element>Custom HTML content</custom-element>',
'**Bold text** in a detailed project summary',
'# Heading in a detailed project summary',
'- A list item in a detailed project summary',
'`Inline code` in a detailed project summary',
]) {
assert.equal(hasProjectSummaryFormatting(summary), true, summary)
}
})
test('requires paired HTML tags in project summaries', () => {
for (const summary of [
'<b>Bold summary',
'Bold summary</strong>',
'A summary with a line break<br>',
'Visible content <!-- hidden HTML content -->',
]) {
assert.equal(hasProjectSummaryFormatting(summary), false, summary)
}
})
test('allows plain-text punctuation in project summaries', () => {
for (const summary of [
'A configuration value named file_name is supported.',
'Use * to mark an important configuration value.',
'The expression 2 < 3 is used as an example.',
'First line\r\nSecond line',
'First paragraph\n\nSecond paragraph',
]) {
assert.equal(hasProjectSummaryFormatting(summary), false, summary)
}
})
test('rejects HTML in disclosure text', () => {
const formattedDisclosures = [
{ type: 'ai_content', uses: [], note: '<b>Bold disclosure</b>' },
{ type: 'advertisements', note: '<strong>HTML disclosure</strong>' },
{ type: 'paid_features', features: ['<code>Paid feature</code>'] },
{ type: 'telemetry', consent: 'opt_in', data_collected: ['<h1>Collected data</h1>'] },
{
type: 'derivative_work',
sources: [{ label: 'Original work', note: '<li>Derived feature</li>' }],
},
{ type: 'epilepsy_triggers', note: '<em>Flashing lights</em>' },
{
type: 'system_interactions',
interactions: [],
note: '<strong>Desktop file access</strong>',
},
{ type: 'archived', note: '<span>No longer maintained</span>' },
] satisfies Labrinth.Projects.v3.ProjectDisclosure[]
for (const disclosure of formattedDisclosures) {
assert.deepEqual(
validateProjectDisclosures([disclosure]).map(({ code }) => code),
['disclosures-special-formatting'],
)
}
const markdownDisclosures = [
{ type: 'ai_content', uses: [], note: '**Bold disclosure**' },
{ type: 'paid_features', features: ['`Paid feature`'] },
{ type: 'telemetry', consent: 'opt_in', data_collected: ['# Collected data'] },
{
type: 'derivative_work',
sources: [{ label: 'Original work', note: '- Derived feature' }],
},
] satisfies Labrinth.Projects.v3.ProjectDisclosure[]
assert.deepEqual(validateProjectDisclosures(markdownDisclosures), [])
const unpairedHtmlDisclosures = [
{ type: 'ai_content', uses: [], note: '<b>Bold disclosure' },
{ type: 'advertisements', note: 'HTML disclosure</strong>' },
{ type: 'paid_features', features: ['A line break<br>'] },
{ type: 'epilepsy_triggers', note: 'Visible content <!-- hidden HTML content -->' },
] satisfies Labrinth.Projects.v3.ProjectDisclosure[]
assert.deepEqual(validateProjectDisclosures(unpairedHtmlDisclosures), [])
assert.deepEqual(
validateProjectDisclosures([
{
type: 'derivative_work',
sources: [
{
label: 'Example project',
link: 'https://example.com/path_with_underscores',
note: 'First line\nSecond line',
},
],
},
]),
[],
)
})
test('rejects every link and IP address in project summaries', () => {
for (const [summary, value] of [
['Visit https://example.dev for more information about this project', 'https://example.dev'],
['Visit example.dev for more information about this project', 'example.dev'],
['Join 127.0.0.1:25565 for more information about this project', '127.0.0.1:25565'],
]) {
assert.deepEqual(
validateProjectSummary({ summary, name: 'Project title' }).map(({ code, values }) => ({
code,
values,
})),
[{ code: 'project-summary-links', values: { value } }],
)
}
assert.deepEqual(
validateProjectSummary({
summary: 'Contact hello@example.com for more information about this project',
name: 'Project title',
}),
[],
)
})
test('rejects configured description links and allows other links', () => {
for (const domain of BANNED_DESCRIPTION_LINK_DOMAINS) {
assert.equal(
findBannedDescriptionLink(`Visit https://${domain}/project`),
`https://${domain}/project`,
)
assert.equal(
findBannedDescriptionLink(`Visit subdomain.${domain}/project`),
`http://subdomain.${domain}/project`,
)
}
assert.equal(findBannedDescriptionLink('Visit https://example.dev/project'), null)
assert.equal(findBannedDescriptionLink('Join 127.0.0.1:25565'), null)
assert.deepEqual(extractDescriptionLinks('Join 127.0.0.1:25565 or visit example.dev'), [
'http://example.dev',
])
})
test('validates description requirements and simultaneous recommendations', () => {
assert.deepEqual(
validateProjectDescription(' ').map(({ code }) => code),
['add-description'],
)
const description = `${'# '.concat('A'.repeat(81))}\n![](one.png)\n![](two.png)\n![](three.png)\n![](four.png)`
assert.deepEqual(
validateProjectDescription(description).map(({ code }) => code),
['description-too-short', 'project-description-spam', 'long-headers', 'missing-alt-text'],
)
})
test('warns when a project description is mostly non-English', () => {
const description = [
'このプロジェクトは設定可能な洞窟と新しい構造物を世界生成に追加します。',
'プレイヤーは設定ファイルを使って、それぞれの機能を個別に変更できます。',
'探索をより楽しくする便利な道具や新しい報酬もたくさん含まれています。',
'サーバーとクライアントの両方で快適に動作するように設計されています。',
].join(' ')
assert.deepEqual(
validateProjectDescription(description)
.filter(({ code }) => code === 'project-description-non-english')
.map(({ code, severity }) => ({ code, severity })),
[{ code: 'project-description-non-english', severity: 'warning' }],
)
})
test('allows short headers regardless of punctuation', () => {
assert.deepEqual(analyzeHeaderLength('# Version 1.2 is available'), {
hasLongHeaders: false,
longHeaders: [],
})
assert.deepEqual(analyzeHeaderLength('# Install version 1.2. Enjoy!'), {
hasLongHeaders: false,
longHeaders: [],
})
})
test('validates Setext headers', () => {
assert.deepEqual(
analyzeHeaderLength('Version 1.2 is available\n===\n\nFirst sentence. Second sentence.\n---'),
{
hasLongHeaders: false,
longHeaders: [],
},
)
assert.deepEqual(analyzeHeaderLength(`${'A'.repeat(81)}\n===`), {
hasLongHeaders: true,
longHeaders: ['A'.repeat(81)],
})
})
test('validates visible header text without counting markup', () => {
const styledHeader =
'<b><font color="#FF5555">W</font><font color="#FFAA00">O</font><font color="#55FF55">W</font><font color="#55FFFF">!</font></b>'
assert.deepEqual(extractRenderedHeaders(`### ${styledHeader}`), ['WOW!'])
assert.deepEqual(analyzeHeaderLength(`### ${styledHeader}`), {
hasLongHeaders: false,
longHeaders: [],
})
const longStyledHeader = `<b>${'A'.repeat(81)}</b>`
assert.deepEqual(analyzeHeaderLength(`### ${longStyledHeader}`), {
hasLongHeaders: true,
longHeaders: ['A'.repeat(81)],
})
assert.deepEqual(analyzeHeaderLength('### <b>First sentence. Second sentence.</b>'), {
hasLongHeaders: false,
longHeaders: [],
})
assert.deepEqual(analyzeHeaderLength(`### [Docs](https://example.com/${'a'.repeat(81)})`), {
hasLongHeaders: false,
longHeaders: [],
})
})
test('validates rendered heading levels one through three using grapheme counts', () => {
assert.deepEqual(analyzeHeaderLength(`<h3>${'A'.repeat(81)}</h3>`), {
hasLongHeaders: true,
longHeaders: ['A'.repeat(81)],
})
assert.deepEqual(analyzeHeaderLength(`<h4>${'A'.repeat(81)}</h4>`), {
hasLongHeaders: false,
longHeaders: [],
})
const emoji = '👨‍👩‍👧‍👦'
assert.deepEqual(analyzeHeaderLength(`### ${emoji.repeat(80)}`), {
hasLongHeaders: false,
longHeaders: [],
})
assert.deepEqual(analyzeHeaderLength(`### ${emoji.repeat(81)}`), {
hasLongHeaders: true,
longHeaders: [emoji.repeat(81)],
})
})
test('rejects descriptions that end with a header', () => {
for (const description of [
'Some content\n\n# Final header',
'Some content\n\n###### Final header',
'Some content\n\nFinal header\n---',
'Some content\n\n<h3>Final header</h3>',
]) {
assert.equal(analyzeHeaderStructure(description).descriptionEndsWithHeader, true)
}
assert.equal(
analyzeHeaderStructure('# Header\n\nContent beneath the header').descriptionEndsWithHeader,
false,
)
})
test('rejects adjacent headers of the same level from one through three', () => {
for (const description of [
'# First\n# Second',
'## First\n\n## Second',
'<h3>First</h3>\n<h3>Second</h3>',
]) {
assert.equal(analyzeHeaderStructure(description).hasAdjacentSameLevelHeaders, true)
}
for (const description of [
'# First\n## Second',
'## First\n\nContent between them\n\n## Second',
'#### First\n#### Second',
]) {
assert.equal(analyzeHeaderStructure(description).hasAdjacentSameLevelHeaders, false)
}
})
test('reports invalid description header structure', () => {
const description = `${'Useful description content. '.repeat(8)}\n\n## First\n## Second`
assert.deepEqual(
validateProjectDescription(description)
.filter(({ code }) => ['description-ends-with-header', 'adjacent-headers'].includes(code))
.map(({ code }) => code),
['description-ends-with-header', 'adjacent-headers'],
)
})
test('requires 125 readable description characters', () => {
const description =
'This project adds useful tools, flexible behavior, accessible documentation, polished gameplay, and support for every player.'
assert.equal(countText(description), MIN_DESCRIPTION_CHARS)
assert.deepEqual(
validateProjectDescription(description.slice(0, -1)).map(({ code }) => code),
['description-too-short'],
)
assert.deepEqual(validateProjectDescription(description), [])
})
test('rejects repeated description padding', () => {
assert.deepEqual(
validateProjectDescription('Useful project! '.repeat(10)).map(({ code }) => code),
['project-description-spam'],
)
})
test('requires alt text for description images', () => {
assert.deepEqual(analyzeImageContent('![Screenshot](screenshot.png)'), {
hasEmptyAltText: false,
})
assert.deepEqual(analyzeImageContent('![](screenshot.png)'), { hasEmptyAltText: true })
assert.deepEqual(analyzeImageContent('<img src="screenshot.png">'), { hasEmptyAltText: true })
})
test('counts image alt text as readable description text', () => {
assert.equal(countText('![Project screenshot](screenshot.png)'), 'Project screenshot'.length)
assert.equal(
countText('<img src="screenshot.png" alt="Project screenshot">'),
'Project screenshot'.length,
)
})
test('counts blockquote content as readable description text', () => {
assert.equal(countText('> Quoted text'), 'Quoted text'.length)
const quotedDescription =
'> This project adds useful tools, flexible behavior, accessible documentation, polished gameplay, and support for every player.'
assert.equal(countText(quotedDescription), MIN_DESCRIPTION_CHARS)
assert.equal(
validateProjectDescription(quotedDescription).some(
({ code }) => code === 'description-too-short',
),
false,
)
})
test('uses the gallery text rules for names and descriptions', () => {
assert.deepEqual(
validateProjectGalleryName('This is $h!t').map(({ code }) => code),
['gallery-text-profanity'],
)
assert.deepEqual(
validateProjectGalleryDescription('𝐁').map(({ code }) => code),
['gallery-text-non-standard'],
)
})
@@ -1,112 +0,0 @@
import { md } from '@modrinth/utils/parse.ts'
import type { ProjectValidationContext } from '../types/nags.ts'
import {
validateEnglishSummaryText,
validateEnglishText,
validateEnglishTextBlocks,
} from '../validators/language/index.ts'
import {
getNonStandardTextRatio,
validateNonStandardText,
} from '../validators/non-standard-text/index.ts'
import { validateProfanity } from '../validators/profanity/index.ts'
import type { ValidationRuleEvaluation } from './types.ts'
const projectPlainTextMarkdown = md({ linkify: false })
const allowedPlainTextBlockTokenTypes = new Set(['paragraph_open', 'inline', 'paragraph_close'])
const allowedPlainTextInlineTokenTypes = new Set(['text', 'softbreak', 'hardbreak'])
export function hasProjectTextFormatting(text: string) {
const hasMarkdownFormatting = projectPlainTextMarkdown.parse(text, {}).some((token) => {
if (token.type === 'html_block') return false
if (!allowedPlainTextBlockTokenTypes.has(token.type)) return true
return (
token.children?.some(
(child) =>
child.type !== 'html_inline' && !allowedPlainTextInlineTokenTypes.has(child.type),
) ?? false
)
})
return hasMarkdownFormatting || hasProjectTextHtmlFormatting(text)
}
const pairedHtmlTagPattern = /<([a-z][\w:-]*)\b[^>]*>[\s\S]*?<\/\1\s*>/i
function hasExplicitlyClosedHtmlElement(html: string) {
return pairedHtmlTagPattern.test(html)
}
export function hasProjectTextHtmlFormatting(text: string) {
const tokens = projectPlainTextMarkdown.parse(text, {})
for (const token of tokens) {
if (token.type === 'html_block' && hasExplicitlyClosedHtmlElement(token.content)) return true
if (!token.children?.some((child) => child.type === 'html_inline')) continue
const inlineHtml = token.children
.filter((child) => child.type !== 'code_inline')
.map((child) => child.content)
.join('')
if (hasExplicitlyClosedHtmlElement(inlineHtml)) return true
}
return false
}
export function normalizeProjectFieldText(value: string) {
return value.trim().normalize('NFC')
}
export function projectRequiresEnglishText(project: ProjectValidationContext['projectV3']) {
return (
(!project.minecraft_java_server &&
!project.categories?.includes('locale') &&
!project.additional_categories?.includes('locale')) ||
project.minecraft_server?.languages?.includes('en') === true
)
}
export function evaluateSlur(text: string): ValidationRuleEvaluation {
const match = validateProfanity(text).matches.find((match) => match.kind === 'slur')
return match ? { valid: false, values: { value: match.rawText } } : { valid: true }
}
export function evaluateProfanity(text: string, maxProfanityCount = 0): ValidationRuleEvaluation {
if (!Number.isInteger(maxProfanityCount) || maxProfanityCount < 0) {
throw new Error('Maximum profanity count must be a non-negative integer')
}
const match = validateProfanity(text).matches.filter((match) => match.kind === 'profanity')[
maxProfanityCount
]
return match ? { valid: false, values: { value: match.rawText } } : { valid: true }
}
export function evaluateNonStandardText(
text: string,
failureThreshold = 0,
): ValidationRuleEvaluation {
const result = validateNonStandardText(text)
return {
valid: result.valid || getNonStandardTextRatio(text, result) < failureThreshold,
}
}
export function evaluateEnglishText(text: string): ValidationRuleEvaluation {
const result = validateEnglishText(text)
return { valid: result.valid }
}
export function evaluateEnglishTextBlocks(blocks: string[]): ValidationRuleEvaluation {
const result = validateEnglishTextBlocks(blocks)
return { valid: result.valid }
}
export function evaluateEnglishSummaryText(text: string): ValidationRuleEvaluation {
const result = validateEnglishSummaryText(text)
return { valid: result.valid }
}
@@ -1,10 +0,0 @@
import type { FieldValidationMessage, ValidationRuleMatch } from './types.ts'
export function toFieldMessages(matches: ValidationRuleMatch[]): FieldValidationMessage[] {
return matches.map((match) => ({
code: match.code,
severity: match.rule.severity,
message: match.message,
values: Object.keys(match.values).length > 0 ? match.values : undefined,
}))
}
@@ -1,41 +0,0 @@
import { useVIntl } from '@modrinth/ui/i18n'
import type { Nag } from '../types/nags.ts'
import { nagDestinations } from './nag-destinations.ts'
import type { ValidationRuleMatch, ValidationRuleSeverity } from './types.ts'
function toNagStatus(severity: ValidationRuleSeverity): Nag['status'] {
if (severity === 'error') return 'required'
return severity
}
export function toNags(matches: ValidationRuleMatch[]): Nag[] {
return matches.map((match) => {
const presentation = match.rule.presentation
const destination = presentation.nag.destination
? nagDestinations[presentation.nag.destination]
: undefined
return {
id: match.code,
title: presentation.nag.title,
description: () => {
const { formatMessage } = useVIntl()
const values = presentation.nag.formatValues
? presentation.nag.formatValues(match.values, formatMessage)
: match.values
return formatMessage(match.message, values)
},
status: toNagStatus(match.rule.severity),
shouldShow: () => true,
...(destination
? {
link: {
...destination,
title: presentation.nag.linkTitle ?? destination.title,
},
}
: {}),
}
})
}
@@ -1,51 +0,0 @@
import type { MessageDescriptor, VIntlFormatters } from '@modrinth/ui'
import type { NagDestinationId } from '../types/nags.ts'
export type ValidationRuleSeverity = 'error' | 'warning' | 'suggestion'
export type ValidationRuleValues = Record<string, string | number | boolean>
export type ValidationRuleValueFormatter = (
values: ValidationRuleValues,
formatMessage: VIntlFormatters['formatMessage'],
) => ValidationRuleValues
export interface ValidationRulePresentation {
message: MessageDescriptor
nag: {
title: MessageDescriptor
destination?: NagDestinationId
linkTitle?: MessageDescriptor
formatValues?: ValidationRuleValueFormatter
}
}
export interface ValidationRuleDefinition {
severity: ValidationRuleSeverity
presentation: ValidationRulePresentation
}
export type ValidationRuleEvaluation =
| { valid: true }
| { valid: false; message?: MessageDescriptor; values?: ValidationRuleValues }
export interface ValidationRule<Input> extends ValidationRuleDefinition {
evaluate: (input: Input) => ValidationRuleEvaluation
}
export type ValidationRuleSet<Input> = Readonly<Record<string, ValidationRule<Input>>>
export interface ValidationRuleMatch<Code extends string = string> {
code: Code
message: MessageDescriptor
rule: ValidationRuleDefinition
values: ValidationRuleValues
}
export interface FieldValidationMessage {
code: string
severity: ValidationRuleSeverity
message: MessageDescriptor
values?: ValidationRuleValues
}
@@ -1,20 +0,0 @@
import { getNags } from '../data/nags.ts'
import type { Nag, ProjectValidationContext } from '../types/nags.ts'
export interface ProjectValidationResult {
valid: boolean
requiredNags: Nag[]
warningNags: Nag[]
}
export function validateProject(context: ProjectValidationContext): ProjectValidationResult {
const nags = getNags(context)
const requiredNags = nags.filter((nag) => nag.status === 'required')
const warningNags = nags.filter((nag) => nag.status === 'warning')
return {
valid: requiredNags.length === 0,
requiredNags,
warningNags,
}
}
@@ -1,145 +0,0 @@
import { francAll } from 'franc-min'
export interface LanguageDetection {
language: string
accuracy: number
}
export interface EnglishTextResult {
valid: boolean
detections: LanguageDetection[]
reasons: EnglishTextFailureReason[]
}
export type EnglishTextFailureReason = 'insufficient-english-chunk-coverage'
export interface LanguageChunkAnalysis {
totalChunks: number
englishChunks: number
nonEnglishChunks: number
ambiguousChunks: number
englishChunkPercentage: number | null
}
export const MIN_LANGUAGE_DETECTION_WORDS = 8
export const MIN_LANGUAGE_DETECTION_CHARACTERS = 35
export const MIN_ENGLISH_SCORE = 0.8
export const MIN_ENGLISH_SUMMARY_SCORE = 0.5
export const LANGUAGE_CHUNK_WORDS = 24
export const LANGUAGE_CHUNK_STRIDE_WORDS = 12
export const MIN_ENGLISH_CHUNK_PERCENTAGE = 0.3
const wordSegmenter = new Intl.Segmenter(undefined, { granularity: 'word' })
const characterSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
function hasEnoughCharacters(text: string): boolean {
let characterCount = 0
for (const _ of characterSegmenter.segment(text.trim())) {
if (++characterCount >= MIN_LANGUAGE_DETECTION_CHARACTERS) return true
}
return false
}
function getWords(text: string): string[] {
return [...wordSegmenter.segment(text)]
.filter(({ isWordLike }) => isWordLike)
.map(({ segment }) => segment)
}
function getWordWindows(block: string): string[] {
const words = getWords(block)
if (words.length < MIN_LANGUAGE_DETECTION_WORDS) return []
if (words.length <= LANGUAGE_CHUNK_WORDS) {
const window = words.join(' ')
return hasEnoughCharacters(window) ? [window] : []
}
const starts = new Set<number>()
for (
let start = 0;
start + MIN_LANGUAGE_DETECTION_WORDS <= words.length;
start += LANGUAGE_CHUNK_STRIDE_WORDS
) {
starts.add(start)
}
starts.add(words.length - LANGUAGE_CHUNK_WORDS)
return [...starts]
.sort((left, right) => left - right)
.map((start) => words.slice(start, start + LANGUAGE_CHUNK_WORDS).join(' '))
.filter(hasEnoughCharacters)
}
export function analyzeLanguageChunks(blocks: string[]): LanguageChunkAnalysis {
let englishChunks = 0
let nonEnglishChunks = 0
let ambiguousChunks = 0
for (const chunk of blocks.flatMap(getWordWindows)) {
const results = francAll(chunk)
const primaryLanguage = results[0]?.[0]
const englishScore = results.find(([language]) => language === 'eng')?.[1] ?? 0
if (primaryLanguage === 'eng') englishChunks++
else if (primaryLanguage && englishScore < MIN_ENGLISH_SCORE) nonEnglishChunks++
else ambiguousChunks++
}
const totalChunks = englishChunks + nonEnglishChunks + ambiguousChunks
const classifiedChunks = englishChunks + nonEnglishChunks
return {
totalChunks,
englishChunks,
nonEnglishChunks,
ambiguousChunks,
englishChunkPercentage: classifiedChunks === 0 ? null : englishChunks / classifiedChunks,
}
}
export function validateEnglishTextBlocks(blocks: string[]): EnglishTextResult {
const normalizedBlocks = blocks.map((block) => block.trim()).filter(Boolean)
const text = normalizedBlocks.join('\n')
const chunkAnalysis = analyzeLanguageChunks(normalizedBlocks)
const valid =
chunkAnalysis.englishChunkPercentage === null ||
chunkAnalysis.englishChunkPercentage >= MIN_ENGLISH_CHUNK_PERCENTAGE
const detections =
getWords(text).length >= MIN_LANGUAGE_DETECTION_WORDS && hasEnoughCharacters(text)
? francAll(text).map(([language, accuracy]) => ({ language, accuracy }))
: []
return {
valid,
detections,
reasons: valid ? [] : ['insufficient-english-chunk-coverage'],
}
}
export function validateEnglishText(text: string): EnglishTextResult {
return validateEnglishTextBlocks(text.split(/\n+/))
}
export function validateEnglishSummaryText(text: string): EnglishTextResult {
const normalizedText = text.trim()
if (
getWords(normalizedText).length < MIN_LANGUAGE_DETECTION_WORDS ||
!hasEnoughCharacters(normalizedText)
) {
return { valid: true, detections: [], reasons: [] }
}
const detections = francAll(normalizedText).map(([language, accuracy]) => ({
language,
accuracy,
}))
const englishScore = detections.find(({ language }) => language === 'eng')?.accuracy ?? 0
const valid = englishScore >= MIN_ENGLISH_SUMMARY_SCORE
return {
valid,
detections,
reasons: valid ? [] : ['insufficient-english-chunk-coverage'],
}
}
@@ -1,146 +0,0 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { francAll } from 'franc-min'
import {
analyzeLanguageChunks,
LANGUAGE_CHUNK_STRIDE_WORDS,
LANGUAGE_CHUNK_WORDS,
MIN_ENGLISH_CHUNK_PERCENTAGE,
MIN_ENGLISH_SCORE,
MIN_ENGLISH_SUMMARY_SCORE,
MIN_LANGUAGE_DETECTION_CHARACTERS,
MIN_LANGUAGE_DETECTION_WORDS,
validateEnglishSummaryText,
validateEnglishText,
validateEnglishTextBlocks,
} from './index.ts'
const english =
'This project adds useful tools, configurable settings, and clear documentation for every player.'
const russian =
'Этот проект добавляет новые инструменты и значительно улучшает игровой процесс для всех игроков.'
test('accepts English text and retains whole-text language diagnostics', () => {
const result = validateEnglishText(english)
const englishDetection = result.detections.find(({ language }) => language === 'eng')
assert.equal(result.valid, true)
assert.ok(englishDetection)
assert.ok(englishDetection.accuracy > MIN_ENGLISH_SCORE)
assert.deepEqual(result.reasons, [])
assert.deepEqual(
result.detections,
francAll(english).map(([language, accuracy]) => ({ language, accuracy })),
)
})
test('rejects text containing only confidently non-English chunks', () => {
for (const text of [
russian,
'これは新しい洞窟と構造物を追加し、すべてのプレイヤーの世界生成を改善するプロジェクトです。',
'Um modpack focado em desempenho, imersão e exploração, mantendo a experiência próxima ao jogo original.',
]) {
const result = validateEnglishText(text)
assert.equal(result.valid, false, text)
assert.deepEqual(result.reasons, ['insufficient-english-chunk-coverage'])
}
})
test('accepts bilingual text when English chunks are 30% of classified chunks', () => {
const blocks = [
english,
english,
english,
russian,
russian,
russian,
russian,
russian,
russian,
russian,
]
const analysis = analyzeLanguageChunks(blocks)
const result = validateEnglishTextBlocks(blocks)
assert.equal(analysis.englishChunks, 3)
assert.equal(analysis.nonEnglishChunks, 7)
assert.equal(analysis.englishChunkPercentage, 0.3)
assert.equal(MIN_ENGLISH_CHUNK_PERCENTAGE, 0.3)
assert.equal(result.valid, true)
assert.deepEqual(result.reasons, [])
})
test('rejects mixed-language text when English chunks are below 30% of classified chunks', () => {
const blocks = [english, english, russian, russian, russian, russian, russian]
const analysis = analyzeLanguageChunks(blocks)
const result = validateEnglishTextBlocks(blocks)
assert.equal(analysis.englishChunks, 2)
assert.equal(analysis.nonEnglishChunks, 5)
assert.ok((analysis.englishChunkPercentage ?? 0) < MIN_ENGLISH_CHUNK_PERCENTAGE)
assert.equal(result.valid, false)
assert.deepEqual(result.reasons, ['insufficient-english-chunk-coverage'])
})
test('uses overlapping 24-word windows with a 12-word stride', () => {
const text = Array.from({ length: 48 }, (_, index) => `word${index}`).join(' ')
const analysis = analyzeLanguageChunks([text])
assert.equal(LANGUAGE_CHUNK_WORDS, 24)
assert.equal(LANGUAGE_CHUNK_STRIDE_WORDS, 12)
assert.equal(analysis.totalChunks, 4)
})
test('skips chunks below the minimum word count', () => {
for (const text of [
'Minecraft',
'Minecraft server',
'This description has only seven English words',
]) {
assert.deepEqual(validateEnglishText(text), { valid: true, detections: [], reasons: [] })
}
assert.equal(MIN_LANGUAGE_DETECTION_WORDS, 8)
})
test('skips chunks below the minimum character count', () => {
assert.deepEqual(validateEnglishText('a b c d e f g h'), {
valid: true,
detections: [],
reasons: [],
})
assert.equal(MIN_LANGUAGE_DETECTION_CHARACTERS, 35)
})
test('accepts summaries with an English score of at least 50% without changing description logic', () => {
const text =
'Um modpack Fabric focado em desempenho, imersão e exploração, mantendo a experiência próxima ao Minecraft Vanilla.'
const summaryResult = validateEnglishSummaryText(text)
const englishDetection = summaryResult.detections.find(({ language }) => language === 'eng')
assert.equal(MIN_ENGLISH_SUMMARY_SCORE, 0.5)
assert.ok(englishDetection)
assert.ok(englishDetection.accuracy >= MIN_ENGLISH_SUMMARY_SCORE)
assert.equal(summaryResult.valid, true)
assert.equal(validateEnglishText(text).valid, false)
})
test('skips summary detection below its word or character minimum', () => {
for (const text of ['This summary has only seven English words', 'a b c d e f g h']) {
assert.deepEqual(validateEnglishSummaryText(text), {
valid: true,
detections: [],
reasons: [],
})
}
assert.equal(MIN_LANGUAGE_DETECTION_WORDS, 8)
assert.equal(MIN_LANGUAGE_DETECTION_CHARACTERS, 35)
})
test('allows empty text to be handled by required-field validation', () => {
assert.deepEqual(validateEnglishText(' '), { valid: true, detections: [], reasons: [] })
})
@@ -1,24 +0,0 @@
export const URL_SHORTENERS = ['bit.ly', 'adf.ly', 'tinyurl.com', 'short.io', 'is.gd'] as const
export const EXTERNAL_LINKS_BLOCK_LIST = [
{ label: 'Twitter', domains: ['twitter.com', 'x.com'] },
{ label: 'Instagram', domains: ['instagram.com'] },
{ label: 'Facebook', domains: ['facebook.com'] },
{ label: 'TikTok', domains: ['tiktok.com'] },
{ label: 'Telegram', domains: ['telegram.org', 't.me'] },
{ label: 'Bilibili', domains: ['bilibili.com'] },
{ label: 'Bluesky', domains: ['bsky.app'] },
{ label: 'Twitch', domains: ['twitch.tv'] },
{ label: 'YouTube', domains: ['youtube.com', 'youtu.be'] },
{ label: 'Reddit', domains: ['reddit.com', 'redd.it'] },
{ label: 'Modrinth', domains: ['modrinth.com'] },
{ label: 'Minecraft', domains: ['minecraft.net'] },
{
label: 'Mod distribution platform',
domains: ['curseforge.com', 'planetminecraft.com', '9minecraft.net', 'mcmod.cn'],
},
{
label: 'AI mod generation platform',
domains: ['creativemode.net', 'orcaclient.com', 'autoforged.cn'],
},
] as const
@@ -1,45 +0,0 @@
export const PROJECT_LINK_DOMAIN_LIST = {
common: {
source: [
'github.com',
'gitlab.com',
'bitbucket.org',
'codeberg.org',
'git.sr.ht',
'tangled.org',
'git.gay',
],
issues: [
'github.com',
'gitlab.com',
'bitbucket.org',
'codeberg.org',
'docs.google.com',
'tangled.org',
'git.gay',
],
discord: ['discord.gg', 'discord.com', 'dsc.gg'],
},
inappropriateLicense: [
'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',
],
} as const
@@ -1,17 +0,0 @@
export { EXTERNAL_LINKS_BLOCK_LIST, URL_SHORTENERS } from './block-list.ts'
export { PROJECT_LINK_DOMAIN_LIST } from './domain-list.ts'
export {
getBlockedProjectExternalLink,
getLinkHostname,
hostnameMatchesDomain,
isCommonProjectLink,
isDiscordLink,
isInappropriateLicenseLink,
} from './syntax-checks.ts'
export type {
BlockedProjectLink,
LinkCheckContext,
LinkCheckResult,
MessageDescriptor,
} from './types.ts'
export { validateLink, validateLinkSyntax } from './validation.ts'
@@ -1,119 +0,0 @@
import type { LinkCheckResult, MessageDescriptor } from './types.ts'
export interface GitRepoFacts {
empty?: boolean
archived?: boolean
issues?: boolean
wiki?: boolean
}
function defineMessage<T extends MessageDescriptor>(descriptor: T): T {
return descriptor
}
function warn(message: MessageDescriptor): LinkCheckResult {
return { severity: 'warn', message }
}
function error(message: MessageDescriptor): LinkCheckResult {
return { severity: 'error', message }
}
export async function checkDiscordInvite(inviteCode: string): Promise<LinkCheckResult> {
const response = await fetch(
`https://discord.com/api/v10/invites/${inviteCode}?with_expiration=true`,
)
if (!response.ok) {
return error(
defineMessage({
id: 'nags.link.discord.invite.invalid',
defaultMessage: 'This Discord invite is invalid or has expired.',
}),
)
}
const invite = await response.json()
if (!invite.guild) {
return error(
defineMessage({
id: 'nags.link.discord.invite.not-guild',
defaultMessage: 'This Discord invite does not lead to a server.',
}),
)
}
if (invite.expires_at) {
return warn(
defineMessage({
id: 'nags.link.discord.invite.expires',
defaultMessage: 'This Discord invite is set to expire',
}),
)
}
return { severity: 'valid' }
}
export async function fetchGiteaRepo(
host: string,
path: string,
): Promise<GitRepoFacts | undefined> {
const response = await fetch(`https://${host}/api/v1/repos/${path}`)
if (!response.ok) return undefined
const data = await response.json()
return {
empty: data.size === 0,
archived: data.archived,
issues: data.has_issues,
wiki: data.has_wiki,
}
}
export async function fetchGitHubRepo(path: string): Promise<GitRepoFacts | undefined> {
const response = await fetch(`https://api.github.com/repos/${path}`)
if (!response.ok) return undefined
const data = await response.json()
return {
empty: data.size === 0,
archived: data.archived,
issues: data.has_issues,
wiki: data.has_wiki,
}
}
export async function fetchGitLabRepo(path: string): Promise<GitRepoFacts | undefined> {
const response = await fetch(`https://gitlab.com/api/v4/projects/${encodeURIComponent(path)}`)
if (!response.ok) return undefined
return {}
}
export async function fetchBitbucketRepo(path: string): Promise<GitRepoFacts | undefined> {
const response = await fetch(`https://api.bitbucket.org/2.0/repositories/${path}`)
if (!response.ok) return undefined
const data = await response.json()
return {
empty: data.size === 0,
issues: data.has_issues,
wiki: data.has_wiki,
}
}
export async function fetchGiteeRepo(path: string): Promise<GitRepoFacts | undefined> {
const response = await fetch(`https://gitee.com/api/v5/repos/${path}`)
if (!response.ok) return undefined
const data = await response.json()
return {
issues: data.has_issues,
wiki: data.has_wiki,
}
}
export async function probeGiteaHost(hostname: string): Promise<boolean> {
try {
const response = await fetch(`https://${hostname}/api/v1/version`)
return response.ok
} catch {
return false
}
}
@@ -1,337 +0,0 @@
import { EXTERNAL_LINKS_BLOCK_LIST, URL_SHORTENERS } from './block-list.ts'
import { PROJECT_LINK_DOMAIN_LIST } from './domain-list.ts'
import type {
BlockedProjectLink,
FieldMatcher,
LinkCheckBuilder,
LinkCheckChildShape,
LinkCheckContext,
LinkCheckMatcher,
LinkCheckNode,
LinkCheckResult,
LinkCheckVerify,
MatchResult,
MessageDescriptor,
RemoteLinkCheckVerify,
} from './types.ts'
function warn(message: MessageDescriptor, values?: Record<string, unknown>): LinkCheckResult {
return { severity: 'warn', message, values }
}
function error(message: MessageDescriptor, values?: Record<string, unknown>): LinkCheckResult {
return { severity: 'error', message, values }
}
export function matchesField(matcher: FieldMatcher, context: LinkCheckContext): boolean {
if (typeof matcher === 'function') return matcher(context.field, context)
if (Array.isArray(matcher)) return matcher.includes(context.field)
return matcher === context.field
}
function isAsyncMatcher(when: LinkCheckMatcher): boolean {
return when instanceof RegExp ? false : when.constructor.name === 'AsyncFunction'
}
function buildNode(when: LinkCheckMatcher, label?: string): LinkCheckBuilder {
const childNodes: LinkCheckNode[] = []
const forMatchers: FieldMatcher[] = []
const node: Record<string, unknown> = { when, label, childNodes, forMatchers }
node.for = (fields: FieldMatcher) => {
forMatchers.push(fields)
return node
}
node.verify = (fn: LinkCheckVerify) => {
node.verifyMatch = fn
return node
}
node.verifyRemotely = (fn: RemoteLinkCheckVerify) => {
node.verifyMatch = fn
node.isRemoteVerification = true
return node
}
node.severity = (value: 'error' | 'warn') => {
node.unrecognizedSeverity = value
return node
}
node.message = (descriptor: MessageDescriptor) => {
node.unrecognizedMessage = descriptor
return node
}
node.transparent = () => {
node.isTransparent = true
return node
}
node.fallback = () => {
node.isFallback = true
return node
}
node.warn = (message: MessageDescriptor, values?: Record<string, unknown>) => {
node.verifyMatch = async () => warn(message, values)
return node
}
node.error = (message: MessageDescriptor, values?: Record<string, unknown>) => {
node.verifyMatch = async () => error(message, values)
return node
}
node.children = (...shapes: LinkCheckChildShape[]) => {
const parentLabel = node.label as string | undefined
const parentForMatchers = node.forMatchers as FieldMatcher[] | undefined
for (const shape of shapes) {
const child = normalizeChild(shape)
const label = child.label ? [parentLabel, child.label].filter(Boolean).join(' ') : parentLabel
const inheritedFor = [...(parentForMatchers ?? []), ...(child.forMatchers ?? [])]
childNodes.push({ ...child, label, forMatchers: inheritedFor })
}
return node
}
return node as unknown as LinkCheckBuilder
}
export function check(
when: RegExp | string | ((remaining: string) => number | null | Promise<number | null>),
label?: string,
): LinkCheckBuilder {
const matcher =
typeof when === 'function' ? when : typeof when === 'string' ? new RegExp(when) : when
return buildNode(matcher, label)
}
export function fallback(label?: string): LinkCheckBuilder {
return buildNode(() => 0, label).fallback()
}
function normalizeChild(shape: LinkCheckChildShape): LinkCheckNode {
if (shape instanceof RegExp || typeof shape === 'function') return { when: shape }
if (typeof shape === 'string') return { when: new RegExp(shape) }
return shape as unknown as LinkCheckNode
}
export function named(label: string, shapes: LinkCheckChildShape[]): LinkCheckNode[] {
return shapes.map((shape) => ({ ...normalizeChild(shape), label }))
}
export function anchored(source: string): RegExp {
return new RegExp(`^${source}`, 'i')
}
export async function matchNode(
node: LinkCheckNode,
remaining: string,
context: LinkCheckContext,
isRoot = false,
): Promise<MatchResult | null> {
let match: RegExpMatchArray | null
if (node.when instanceof RegExp) {
match = node.when.exec(remaining)
} else {
const consumed = await node.when(remaining)
match =
consumed === null
? null
: (Object.assign([remaining.slice(0, consumed)], {
input: remaining,
index: 0,
}) as RegExpMatchArray)
}
if (!match) {
if (!isRoot || !node.unrecognizedMessage) return null
return {
node: {
when: node.when,
label: node.label,
unrecognizedMessage: node.unrecognizedMessage,
unrecognizedSeverity: node.unrecognizedSeverity,
},
match: Object.assign([remaining], { input: remaining, index: 0 }) as RegExpMatchArray,
}
}
if (node.childNodes?.length) {
const rest = remaining.slice(match[0].length)
const syncChildren = node.childNodes.filter(
(child) => !isAsyncMatcher(child.when) && !child.isFallback,
)
const asyncChildren = node.childNodes.filter(
(child) =>
isAsyncMatcher(child.when) &&
!child.isFallback &&
!(context.generalContent && hasFieldSpecificDescendant(child)),
)
const fallbackChildren = node.childNodes.filter((child) => child.isFallback)
let expectedChild: LinkCheckNode | undefined
for (const child of [...syncChildren, ...asyncChildren]) {
const found = await matchNode(child, rest, context)
if (found) return found
if (!expectedChild && child.forMatchers?.some((matcher) => matchesField(matcher, context)))
expectedChild = child
}
const matchingFallback = fallbackChildren.find((child) =>
child.forMatchers?.some((matcher) => matchesField(matcher, context)),
)
if (matchingFallback) {
return {
node: matchingFallback,
match: Object.assign([rest], { input: rest, index: 0 }) as RegExpMatchArray,
}
}
if (node.isTransparent) return null
return { node, match, expectedChild }
}
return { node, match }
}
export function matchNodeSyntax(
node: LinkCheckNode,
remaining: string,
context: LinkCheckContext,
isRoot = false,
): MatchResult | null {
let match: RegExpMatchArray | null
if (node.when instanceof RegExp) {
match = node.when.exec(remaining)
} else {
if (isAsyncMatcher(node.when)) return null
const consumed = node.when(remaining)
if (consumed instanceof Promise) return null
match =
consumed === null
? null
: (Object.assign([remaining.slice(0, consumed)], {
input: remaining,
index: 0,
}) as RegExpMatchArray)
}
if (!match) {
if (!isRoot || !node.unrecognizedMessage) return null
return {
node: {
when: node.when,
label: node.label,
unrecognizedMessage: node.unrecognizedMessage,
unrecognizedSeverity: node.unrecognizedSeverity,
},
match: Object.assign([remaining], { input: remaining, index: 0 }) as RegExpMatchArray,
}
}
if (node.childNodes?.length) {
const rest = remaining.slice(match[0].length)
const children = node.childNodes.filter(
(child) => !isAsyncMatcher(child.when) && !child.isFallback,
)
const fallbackChildren = node.childNodes.filter((child) => child.isFallback)
let expectedChild: LinkCheckNode | undefined
for (const child of children) {
const found = matchNodeSyntax(child, rest, context)
if (found) return found
if (!expectedChild && child.forMatchers?.some((matcher) => matchesField(matcher, context)))
expectedChild = child
}
const matchingFallback = fallbackChildren.find((child) =>
child.forMatchers?.some((matcher) => matchesField(matcher, context)),
)
if (matchingFallback) {
return {
node: matchingFallback,
match: Object.assign([rest], { input: rest, index: 0 }) as RegExpMatchArray,
}
}
if (node.isTransparent) return null
return { node, match, expectedChild }
}
return { node, match }
}
export function validUrlPrefix(remaining: string): number | null {
let url: URL
try {
url = new URL(remaining)
const hostname = url.hostname
if (url.protocol !== 'https:') return null
if (!/[^.]\.[^.]/.test(hostname)) return null
if (/(^|\.)(local|localhost|test|example|invalid|onion|arpa|home)$/i.test(hostname)) return null
if (/^example\.(com|net|org)$/i.test(hostname)) return null
const strippedHost = hostname.replace(/^\[|]$/g, '')
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(strippedHost) || strippedHost.includes(':')) return null
const protocolPrefix = /^https:\/\//i.exec(remaining)
return protocolPrefix?.[0].length ?? null
} catch {
return null
}
}
export function getLinkHostname(url: string | null | undefined): string | null {
if (!url) return null
try {
const hostname = new URL(url).hostname.toLowerCase().replace(/\.$/, '')
return hostname || null
} catch {
return null
}
}
export function hostnameMatchesDomain(hostname: string, domain: string): boolean {
return hostname === domain || hostname.endsWith(`.${domain}`)
}
function isLinkFromDomains(url: string | null | undefined, domains: readonly string[]): boolean {
const hostname = getLinkHostname(url)
return hostname !== null && domains.some((domain) => hostnameMatchesDomain(hostname, domain))
}
export function isCommonProjectLink(
url: string | null | undefined,
field: keyof typeof PROJECT_LINK_DOMAIN_LIST.common,
): boolean {
return isLinkFromDomains(url, PROJECT_LINK_DOMAIN_LIST.common[field])
}
export function isDiscordLink(url: string | null | undefined): boolean {
return isCommonProjectLink(url, 'discord')
}
export function isInappropriateLicenseLink(url: string | null | undefined): boolean {
return isLinkFromDomains(url, PROJECT_LINK_DOMAIN_LIST.inappropriateLicense)
}
export function hasFieldSpecificDescendant(node: LinkCheckNode): boolean {
return (
(node.forMatchers?.length ?? 0) > 0 ||
(node.childNodes?.some((child) => hasFieldSpecificDescendant(child)) ?? false)
)
}
export function isIpAddress(hostname: string): boolean {
const strippedHostname = hostname.replace(/^\[|]$/g, '')
return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(strippedHostname) || strippedHostname.includes(':')
}
export function getBlockedProjectExternalLink(url: string): BlockedProjectLink | null {
const hostname = getLinkHostname(url)
if (!hostname) return null
if (isIpAddress(hostname)) return { label: 'IP address', url }
if (URL_SHORTENERS.some((domain) => hostnameMatchesDomain(hostname, domain))) {
return { label: 'URL shortener', url }
}
const entry = EXTERNAL_LINKS_BLOCK_LIST.find(({ domains }) =>
domains.some((domain) => hostnameMatchesDomain(hostname, domain)),
)
return entry ? { label: entry.label, url } : null
}
@@ -1,196 +0,0 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
EXTERNAL_LINKS_BLOCK_LIST,
getBlockedProjectExternalLink,
getLinkHostname,
isCommonProjectLink,
isDiscordLink,
isInappropriateLicenseLink,
URL_SHORTENERS,
validateLink,
validateLinkSyntax,
} from './index.ts'
test('rejects invalid and insecure URLs', async () => {
const insecure = await validateLink({
field: 'source',
url: 'http://github.com/modrinth/code',
})
const reserved = await validateLink({
field: 'source',
url: 'https://example.com/project',
})
assert.equal(insecure?.severity, 'error')
assert.equal(reserved?.severity, 'error')
})
test('uses a description-specific message for invalid content links', async () => {
const result = await validateLink({
field: 'description',
url: 'http://example.dev/project',
generalContent: true,
})
assert.equal(result?.message?.id, 'nags.link.description.invalid-url')
assert.equal(result?.message?.defaultMessage, 'The description has an invalid link: “{fullUrl}”.')
assert.deepEqual(result?.values, { fullUrl: 'http://example.dev/project' })
})
test('matches recognized hosts case-insensitively', async () => {
const googleForm = await validateLink({
field: 'issues',
url: 'https://DOCS.GOOGLE.COM/forms/d/e/example',
})
const shortener = await validateLink({ field: 'source', url: 'https://BIT.LY/example' })
assert.equal(googleForm?.severity, 'valid')
assert.equal(shortener?.severity, 'error')
})
test('rejects a recognized link used in the wrong field', async () => {
const result = await validateLink({
field: 'wiki',
url: 'https://docs.google.com/forms/d/e/example',
})
assert.equal(result?.severity, 'error')
assert.equal(result?.message?.id, 'nags.link.wrong-field')
})
test('allows structured link types in general content', async () => {
const result = await validateLink({
field: 'description',
url: 'https://github.com/modrinth/code',
generalContent: true,
})
assert.equal(result?.severity, 'valid')
})
test('allows unrecognized valid links in general content', async () => {
const allowed = await validateLink({
field: 'description',
url: 'https://docs.example.dev/project',
generalContent: true,
})
const shortener = await validateLink({
field: 'description',
url: 'https://bit.ly/project',
generalContent: true,
})
assert.equal(allowed?.severity, 'valid')
assert.equal(shortener?.severity, 'valid')
})
test('applies the external-link blocklist only outside general content', async () => {
const blockedExternalLink = await validateLink({
field: 'site',
url: 'https://social.modrinth.com/project',
})
const allowedContentLink = await validateLink({
field: 'description',
url: 'https://social.modrinth.com/project',
generalContent: true,
})
const allowed = await validateLink({
field: 'description',
url: 'https://modrinth.com.example.dev/project',
generalContent: true,
})
assert.equal(blockedExternalLink?.severity, 'error')
assert.equal(allowedContentLink?.severity, 'valid')
assert.equal(allowed?.severity, 'valid')
})
test('compares recognized license URLs with the selected license', async () => {
const matching = await validateLink({
field: 'license',
url: 'https://spdx.org/licenses/MIT.html',
expectedLicense: 'MIT',
isCustom: false,
})
const mismatching = await validateLink({
field: 'license',
url: 'https://spdx.org/licenses/MIT.html',
expectedLicense: 'Apache-2.0',
isCustom: false,
})
assert.equal(matching?.severity, 'valid')
assert.equal(mismatching?.severity, 'warn')
})
test('blocks every configured URL shortener and its subdomains', () => {
for (const domain of URL_SHORTENERS) {
assert.equal(
getBlockedProjectExternalLink(`https://subdomain.${domain}/project`)?.label,
'URL shortener',
)
}
})
test('blocks every configured external domain and its subdomains', () => {
for (const { label, domains } of EXTERNAL_LINKS_BLOCK_LIST) {
for (const domain of domains) {
assert.deepEqual(getBlockedProjectExternalLink(`https://${domain}/project`), {
label,
url: `https://${domain}/project`,
})
assert.equal(
getBlockedProjectExternalLink(`https://subdomain.${domain}/project`)?.label,
label,
)
}
}
})
test('blocks configured external links', () => {
assert.equal(
getBlockedProjectExternalLink('https://social.modrinth.com/project')?.label,
'Modrinth',
)
})
test('blocks IP-address URLs without blocking domain lookalikes', () => {
assert.equal(getBlockedProjectExternalLink('http://127.0.0.1:25565')?.label, 'IP address')
assert.equal(getBlockedProjectExternalLink('https://[2001:db8::1]')?.label, 'IP address')
assert.equal(getBlockedProjectExternalLink('https://modrinth.com.example.dev'), null)
assert.equal(getBlockedProjectExternalLink('not a URL'), null)
})
test('matches classified domains exactly or by subdomain', () => {
assert.equal(isCommonProjectLink('https://github.com/modrinth/code', 'source'), true)
assert.equal(isCommonProjectLink('https://subdomain.github.com/modrinth/code', 'source'), true)
assert.equal(isCommonProjectLink('https://fakegithub.com/modrinth/code', 'source'), false)
assert.equal(isCommonProjectLink('https://github.com.example.com/modrinth/code', 'source'), false)
assert.equal(isDiscordLink('https://discord.gg/modrinth'), true)
assert.equal(isDiscordLink('https://discord.gg.example.com/modrinth'), false)
assert.equal(isInappropriateLicenseLink('https://youtube.com/watch?v=example'), true)
assert.equal(isInappropriateLicenseLink('https://youtube.com.evil.dev/license'), false)
})
test('extracts normalized hostnames from valid web URLs', () => {
assert.equal(getLinkHostname('https://GITHUB.COM./modrinth/code'), 'github.com')
assert.equal(getLinkHostname('not a URL'), null)
assert.equal(getLinkHostname('mailto:example@example.com'), null)
})
test('validates recognized link syntax without performing remote checks', () => {
const source = validateLinkSyntax({
field: 'source',
url: 'https://github.com/modrinth/code',
})
const wrongField = validateLinkSyntax({
field: 'wiki',
url: 'https://docs.google.com/forms/d/e/example',
})
assert.equal(source?.severity, 'valid')
assert.equal(wrongField?.severity, 'error')
assert.equal(wrongField?.message?.id, 'nags.link.wrong-field')
})
@@ -1,93 +0,0 @@
export interface MessageDescriptor {
id: string
defaultMessage?: string
description?: string
}
export interface LinkCheckContext {
url: string | undefined
field: string
generalContent?: boolean
[key: string]: unknown
}
export interface LinkCheckResult {
severity: 'valid' | 'warn' | 'error'
message?: MessageDescriptor
values?: Record<string, unknown>
}
export interface BlockedProjectLink extends Record<string, unknown> {
label: string
url: string
}
export type FieldMatcher =
| string
| string[]
| ((field: string, context: LinkCheckContext) => boolean)
export type LinkCheckVerify = (
match: RegExpMatchArray,
context: LinkCheckContext,
) => LinkCheckResult
export type RemoteLinkCheckVerify = (
match: RegExpMatchArray,
context: LinkCheckContext,
) => Promise<LinkCheckResult>
export type LinkCheckMatcher =
| RegExp
| ((remaining: string) => number | null | Promise<number | null>)
export interface LinkCheckNode {
when: LinkCheckMatcher
label?: string
unrecognizedSeverity?: 'error' | 'warn'
unrecognizedMessage?: MessageDescriptor
forMatchers?: FieldMatcher[]
verifyMatch?: LinkCheckVerify | RemoteLinkCheckVerify
isRemoteVerification?: boolean
childNodes?: LinkCheckNode[]
isTransparent?: boolean
isFallback?: boolean
}
export interface LinkCheckBuilder {
when: LinkCheckMatcher
label?: string
for(fields: FieldMatcher): LinkCheckBuilder
verify(fn: LinkCheckVerify): LinkCheckBuilder
verifyRemotely(fn: RemoteLinkCheckVerify): LinkCheckBuilder
children(...shapes: LinkCheckChildShape[]): LinkCheckBuilder
severity(value: 'error' | 'warn'): LinkCheckBuilder
message(descriptor: MessageDescriptor): LinkCheckBuilder
transparent(): LinkCheckBuilder
fallback(): LinkCheckBuilder
warn(message: MessageDescriptor, values?: Record<string, unknown>): LinkCheckBuilder
error(message: MessageDescriptor, values?: Record<string, unknown>): LinkCheckBuilder
}
export type LinkCheckChildShape =
| LinkCheckNode
| LinkCheckBuilder
| RegExp
| string
| ((remaining: string) => number | null | Promise<number | null>)
export interface MatchResult {
node: LinkCheckNode
match: RegExpMatchArray
expectedChild?: LinkCheckNode
}
@@ -1,465 +0,0 @@
import {
checkDiscordInvite,
fetchBitbucketRepo,
fetchGiteaRepo,
fetchGiteeRepo,
fetchGitHubRepo,
fetchGitLabRepo,
type GitRepoFacts,
probeGiteaHost,
} from './remote-checks.ts'
import {
anchored,
check,
fallback,
getBlockedProjectExternalLink,
hasFieldSpecificDescendant,
matchesField,
matchNode,
matchNodeSyntax,
named,
validUrlPrefix,
} from './syntax-checks.ts'
import type {
LinkCheckBuilder,
LinkCheckContext,
LinkCheckNode,
LinkCheckResult,
MatchResult,
MessageDescriptor,
} from './types.ts'
function defineMessage<T extends MessageDescriptor>(descriptor: T): T {
return descriptor
}
function defineMessages<T extends Record<string, MessageDescriptor>>(descriptors: T): T {
return descriptors
}
const valid: LinkCheckResult = { severity: 'valid' }
function warn(message: MessageDescriptor, values?: Record<string, unknown>): LinkCheckResult {
return { severity: 'warn', message, values }
}
function error(message: MessageDescriptor, values?: Record<string, unknown>): LinkCheckResult {
return { severity: 'error', message, values }
}
const coreMessages = defineMessages({
wrongField: {
id: 'nags.link.wrong-field',
defaultMessage: "{label} links aren't valid for this field.",
},
neverValid: {
id: 'nags.link.never-valid',
defaultMessage: "{label} links aren't allowed here.",
},
expectedType: {
id: 'nags.link.expected-type',
defaultMessage: "This isn't a valid {label} link.",
},
})
const invalidUrlMessage = defineMessage({
id: 'nags.link.invalid-url',
defaultMessage: 'This URL is invalid',
})
const invalidDescriptionUrlMessage = defineMessage({
id: 'nags.link.description.invalid-url',
defaultMessage: 'The description has an invalid link: “{fullUrl}”.',
})
const checks = check(validUrlPrefix).message(invalidUrlMessage).transparent()
const rootNode = checks as unknown as LinkCheckNode
type PreparedLinkValidation =
| LinkCheckResult
| (() => LinkCheckResult | Promise<LinkCheckResult>)
| undefined
function prepareMatchedLinkValidation(
context: LinkCheckContext,
found: MatchResult,
includeRemoteChecks: boolean,
): PreparedLinkValidation {
const { node: matched, match, expectedChild } = found
const isLeaf = !matched.childNodes?.length
const applies = isLeaf && matched.forMatchers?.some((matcher) => matchesField(matcher, context))
if (!applies) {
if (context.generalContent && hasFieldSpecificDescendant(matched)) return valid
const build = matched.unrecognizedSeverity === 'warn' ? warn : error
if (matched.unrecognizedMessage && isLeaf) {
const isInvalidDescriptionUrl =
context.field === 'description' && matched.unrecognizedMessage.id === invalidUrlMessage.id
const message = isInvalidDescriptionUrl
? invalidDescriptionUrlMessage
: matched.unrecognizedMessage
const values = isInvalidDescriptionUrl ? { fullUrl: context.url } : { label: matched.label }
return build(message, values)
}
if (expectedChild) {
if (matched.unrecognizedMessage) {
return build(matched.unrecognizedMessage, { label: matched.label })
}
return build(coreMessages.expectedType, { label: expectedChild.label })
}
const validElsewhere = matched.forMatchers && matched.forMatchers.length > 0
const message = validElsewhere ? coreMessages.wrongField : coreMessages.neverValid
return build(message, { label: matched.label })
}
if (!matched.verifyMatch || (matched.isRemoteVerification && !includeRemoteChecks)) return valid
return () => matched.verifyMatch!(match, context)
}
function getBlockedExternalLinkResult(context: LinkCheckContext): LinkCheckResult | undefined {
const url = context.url
if (!url || context.generalContent) return
const blockedLink = getBlockedProjectExternalLink(url)
return blockedLink ? error(coreMessages.neverValid, { label: blockedLink.label }) : undefined
}
export function validateLinkSyntax(context: LinkCheckContext): LinkCheckResult | undefined {
const url = context.url
if (!url) return
const blockedResult = getBlockedExternalLinkResult(context)
if (blockedResult) return blockedResult
const normalizedUrl = url.replace(/^(https:\/\/)www\./i, '$1')
const found = matchNodeSyntax(rootNode, normalizedUrl, context, true)
if (!found)
return context.generalContent && validUrlPrefix(normalizedUrl) !== null ? valid : undefined
const prepared = prepareMatchedLinkValidation(context, found, false)
if (typeof prepared !== 'function') return prepared
const result = prepared()
return result instanceof Promise ? undefined : result
}
export async function validateLink(
context: LinkCheckContext,
): Promise<LinkCheckResult | undefined> {
const url = context.url
if (!url) return
const blockedResult = getBlockedExternalLinkResult(context)
if (blockedResult) return blockedResult
const normalizedUrl = url.replace(/^(https:\/\/)www\./i, '$1')
const found = await matchNode(rootNode, normalizedUrl, context, true)
if (!found) {
return context.generalContent && validUrlPrefix(normalizedUrl) !== null ? valid : undefined
}
const prepared = prepareMatchedLinkValidation(context, found, true)
if (typeof prepared !== 'function') return prepared
try {
return await prepared()
} catch {
return undefined
}
}
checks.children(
...named('Discord', [
check(/^discord\.gg/i).children(
check(/^\/([\w-]+)/i)
.for('discord')
.verifyRemotely((match) => checkDiscordInvite(match[1])),
),
check(/^(?:discord\.com|discordapp\.com)/i).children(
check(/^\/invite\/([\w-]+)/i)
.for('discord')
.verifyRemotely((match) => checkDiscordInvite(match[1])),
check(/^\/channels\//i).message(
defineMessage({
id: 'nags.link.discord.channel',
defaultMessage: 'This is a link to a Discord channel, not a server invite.',
}),
),
check(/^\/users\//i).message(
defineMessage({
id: 'nags.link.discord.user',
defaultMessage: 'This is a link to a Discord user, not a server invite.',
}),
),
),
]),
)
const gitRepoMessages = defineMessages({
notFound: {
id: 'nags.link.git.not-found',
defaultMessage: 'This repository could not be found (it may be private or deleted).',
},
empty: {
id: 'nags.link.git.empty',
defaultMessage: 'This repository appears to be empty.',
},
archived: {
id: 'nags.link.git.archived',
defaultMessage: 'This repository is archived, which disables issues.',
},
issuesDisabled: {
id: 'nags.link.git.issues-disabled',
defaultMessage: 'Issues are disabled on this repository.',
},
wikiDisabled: {
id: 'nags.link.git.wiki-disabled',
defaultMessage: 'The wiki is disabled on this repository.',
},
})
async function checkRepo(
fetchRepo: (path: string) => Promise<GitRepoFacts | undefined>,
path: string,
evaluate: (facts: GitRepoFacts) => LinkCheckResult,
): Promise<LinkCheckResult> {
const facts = await fetchRepo(path)
if (!facts) return error(gitRepoMessages.notFound)
return evaluate(facts)
}
function gitHost(
name: string,
domain: string,
fetchRepo: (path: string) => Promise<GitRepoFacts | undefined>,
options: {
pathPattern?: string
subPageSeparator?: string
wikiPath?: string
} = {},
): LinkCheckBuilder {
const path = options.pathPattern ?? /[^/]+\/[^/]+/.source
const sep = options.subPageSeparator ?? ''
const wikiPath = options.wikiPath ?? 'wiki'
return check(anchored(domain), name)
.severity('warn')
.children(
check(anchored(`/(${path})/?$`), 'repo')
.for('source')
.verifyRemotely((match) =>
checkRepo(fetchRepo, match[1], (facts) =>
facts.empty ? error(gitRepoMessages.empty) : valid,
),
),
check(anchored(`/(${path})${sep}/issues`), 'issues')
.for('issues')
.verifyRemotely((match) =>
checkRepo(fetchRepo, match[1], (facts) => {
if (facts.archived) return error(gitRepoMessages.archived)
if (facts.issues === false) return error(gitRepoMessages.issuesDisabled)
return valid
}),
),
check(anchored(`/(${path})${sep}/${wikiPath}`), 'wiki')
.for('wiki')
.verifyRemotely((match) =>
checkRepo(fetchRepo, match[1], (facts) =>
facts.wiki === false ? error(gitRepoMessages.wikiDisabled) : valid,
),
),
)
}
// Repo Platforms, includes most source/issues/wiki + Github Sponsor
checks.children(
gitHost('GitHub', 'github\\.com', fetchGitHubRepo)
// Github sponsor is here
//TODO: we can't actually check if sponsors is setup with auth or cuz of cors im not really sure but regardless it doesn't works from browser
.children(check(/^\/sponsors\/[^/]+/i, 'sponsors').for('github')),
gitHost('Codeberg', 'codeberg\\.org', (path) => fetchGiteaRepo('codeberg.org', path)),
gitHost('GitLab', 'gitlab\\.com', fetchGitLabRepo, {
pathPattern: /[^/]+(?:\/[^/]+)+/.source,
subPageSeparator: '/-',
wikiPath: 'wikis',
}),
gitHost('Bitbucket', 'bitbucket\\.org', fetchBitbucketRepo),
gitHost('Gitee', 'gitee\\.com', fetchGiteeRepo),
)
checks.children(
check(async (remaining) => {
const hostMatch = /^[^/]+/.exec(remaining)
if (!hostMatch) return null
return (await probeGiteaHost(hostMatch[0])) ? 0 : null
}, 'Self-hosted Gitea/Forgejo')
.severity('warn')
.children(
check(/^([^/]+)\/([^/]+\/[^/]+)\/?$/i, 'repo')
.for('source')
.verifyRemotely((match) =>
checkRepo(
(path) => fetchGiteaRepo(match[1], path),
match[2],
(facts) => (facts.empty ? error(gitRepoMessages.empty) : valid),
),
),
check(/^([^/]+)\/([^/]+\/[^/]+)\/issues/i, 'issues')
.for('issues')
.verifyRemotely((match) =>
checkRepo(
(path) => fetchGiteaRepo(match[1], path),
match[2],
(facts) => {
if (facts.archived) return error(gitRepoMessages.archived)
if (facts.issues === false) return error(gitRepoMessages.issuesDisabled)
return valid
},
),
),
check(/^([^/]+)\/([^/]+\/[^/]+)\/wiki/i, 'wiki')
.for('wiki')
.verifyRemotely((match) =>
checkRepo(
(path) => fetchGiteaRepo(match[1], path),
match[2],
(facts) => (facts.wiki === false ? error(gitRepoMessages.wikiDisabled) : valid),
),
),
),
)
// Donation
checks.children(
check(/^patreon\.com/i, 'Patreon').children(check(/^\/(?:user\?u=\d+|[\w.-]+)/i).for('patreon')),
check(/^(?:buymeacoffee\.com|buymeacoff\.ee)/i, 'Buy Me a Coffee').children(
check(/^\/([\w-]+)/i).for('bmac'),
),
check(/^paypal\.[a-z.]{2,}/i, 'PayPal')
.for('paypal')
.children(
check(/^\/paypalme\/[\w.-]+/i),
check(/^\/donate/i),
check(/^\/cgi-bin\/webscr\?cmd=_donations/i),
),
check(/^paypal\.me/i, 'PayPal').children(check(/^\/([\w.-]+)/i).for('paypal')),
// Github sponsor is with the rest of github.
check(/^ko-fi\.com/i, 'Ko-fi').children(check(/^\/([\w-]+)/i).for('ko-fi')),
(() => {
const YOUTUBE_CHANNEL = '(?:@[\\w.-]+|channel/[\\w-]+|c/[\\w-]+|user/[\\w-]+)'
return check(/^(?:youtube\.com|youtu\.be)/i, 'YouTube')
.message(
defineMessage({
id: 'nags.link.youtube.unrecognized',
defaultMessage: "This doesn't look like a YouTube donation link.",
}),
)
.for('other')
.children(
check(anchored(`/${YOUTUBE_CHANNEL}/join`)),
check(anchored(`/${YOUTUBE_CHANNEL}/store`)),
)
})(),
)
//TODO: remove this if/when we move this to the backend as we can know this if its backend
// tho actually we will probably still need it even then if we're fine with non immediate redirects
// we at the very least need to reword it in that case idk man
checks.children(
fallback('Unrecognized redirect link')
.for(['discord', 'github', 'patreon', 'ko-fi', 'paypal', 'bmac'])
.verify((_match, context) =>
warn(
defineMessage({
id: 'nags.link.unverifiable-redirect',
defaultMessage: "This doesn't look like a {platform} link.",
}),
{ platform: context.platformName ?? context.field },
),
),
)
const licenseCheckMessages = defineMessages({
urlMismatch: {
id: 'nags.link.license.url-mismatch',
defaultMessage:
'This link points to the {detected} license, but your project is set to {selected}.',
},
urlRedundant: {
id: 'nags.link.license.url-redundant',
defaultMessage:
"You don't need to link to a generic license page for a supported license — consider linking to your repository's own license file instead, or leaving this blank.",
},
})
function licenseVerify(detected: string | null, context: Record<string, unknown>): LinkCheckResult {
const expectedLicense = context.expectedLicense as string | undefined
const isCustom = context.isCustom as boolean | undefined
if (detected && expectedLicense && !isCustom) {
return detected.toLowerCase() === expectedLicense.toLowerCase()
? valid
: warn(licenseCheckMessages.urlMismatch, { detected, selected: expectedLicense })
}
return isCustom ? valid : warn(licenseCheckMessages.urlRedundant)
}
checks.children(
check(anchored('spdx\\.org'), 'SPDX').children(
check(anchored('/licenses/([\\w.-]+)\\.html'))
.for('license')
.verify((match, ctx) => licenseVerify(match[1], ctx)),
),
check(anchored('opensource\\.org'), 'OSI').children(
check(anchored('/licenses?/([\\w.-]+)'))
.for('license')
.verify((match, ctx) => licenseVerify(match[1], ctx)),
),
check(anchored('choosealicense\\.com'), 'choosealicense.com').children(
check(anchored('/licenses/([\\w.-]+)'))
.for('license')
.verify((match, ctx) => licenseVerify(match[1], ctx)),
),
check(anchored('(?:www\\.)?gnu\\.org'), 'GNU').children(
check(anchored('/licenses/[\\w.-]+'))
.for('license')
.verify((_match, ctx) => licenseVerify(null, ctx)),
),
check(anchored('(?:www\\.)?apache\\.org'), 'Apache').children(
check(anchored('/licenses/[\\w.-]+'))
.for('license')
.verify((_match, ctx) => licenseVerify(null, ctx)),
),
check(anchored('creativecommons\\.org'), 'Creative Commons').children(
check(anchored('/(?:licenses/[\\w-]+|publicdomain/zero)/[\\d.]+/?'))
.for('license')
.verify((_match, ctx) => licenseVerify(null, ctx)),
),
)
// Google Forms for issues and Docs for Wiki
checks.children(
check(/^docs\.google\.com/i, 'Google').children(
check(/^\/forms\//i, 'Forms').for('issues'),
check(/^\/document\//i, 'Documents').for('wiki'),
),
)
@@ -1,282 +0,0 @@
// the following are non-standard text that are detected
export type NonStandardTextIssueKind =
| 'fancy' // styled characters such as `𝐀`, `Ⓐ`, or ``.
| 'zalgo' // detached or excessive combining marks such as `a̴̵̶`.
| 'invisible' // hidden formatting such as a word joiner or bidi override.
| 'control' // disallowed control characters such as a null byte.
| 'private-use' // characters from Unicode private-use areas.
| 'unassigned' // code points with no assigned Unicode character.
| 'surrogate' // malformed standalone UTF-16 surrogate code units.
export interface NonStandardTextIssue {
kind: NonStandardTextIssueKind
character: string
codePoint: string
index: number
}
export interface NonStandardTextResult {
valid: boolean
issues: NonStandardTextIssue[]
counts: Record<NonStandardTextIssueKind, number>
}
export interface NonStandardTextOptions {
allowNewlines?: boolean
allowTabs?: boolean
maxCombiningMarksPerCharacter?: number
}
export const DEFAULT_MAX_COMBINING_MARKS_PER_CHARACTER = 2
export function getNonStandardTextRatio(text: string, result: NonStandardTextResult): number {
const characterCount = Array.from(text).length
if (characterCount === 0) return 0
const nonStandardCharacterCount = new Set(result.issues.map(({ index }) => index)).size
return nonStandardCharacterCount / characterCount
}
const FANCY_RANGES: ReadonlyArray<readonly [number, number]> = [
[0x02b0, 0x02ff],
[0x1d400, 0x1d7ff],
[0x2460, 0x24ff],
[0x2070, 0x209f],
[0x2100, 0x214f],
[0xfb00, 0xfb06],
[0xff10, 0xff19],
[0xff21, 0xff3a],
[0xff41, 0xff5a],
[0x1f100, 0x1f1ad],
]
const ALLOWED_FANCY_CODE_POINTS = new Set([
0x02d6, 0x02d7, 0x02d8, 0x02d9, 0x02da, 0x02db, 0x02dc, 0x02dd, 0x207a, 0x207b, 0x208a, 0x208b,
0x2120, 0x2122, 0x2139,
])
const MARK_PATTERN = /\p{M}/u
const CONTROL_PATTERN = /\p{Cc}/u
const FORMAT_PATTERN = /\p{Cf}/u
const PRIVATE_USE_PATTERN = /\p{Co}/u
const UNASSIGNED_PATTERN = /\p{Cn}/u
const LETTER_PATTERN = /\p{L}/u
const EXTENDED_PICTOGRAPHIC_PATTERN = /\p{Extended_Pictographic}/u
const EMOJI_PRESENTATION_PATTERN = /\p{Emoji_Presentation}/u
const UNIFIED_IDEOGRAPH_PATTERN = /\p{Unified_Ideograph}/u
function createCounts(): Record<NonStandardTextIssueKind, number> {
return {
fancy: 0,
zalgo: 0,
invisible: 0,
control: 0,
'private-use': 0,
unassigned: 0,
surrogate: 0,
}
}
function isInRanges(codePoint: number, ranges: ReadonlyArray<readonly [number, number]>) {
return ranges.some(([start, end]) => codePoint >= start && codePoint <= end)
}
function isVariationSelector(codePoint: number) {
return (
(codePoint >= 0xfe00 && codePoint <= 0xfe0f) || (codePoint >= 0xe0100 && codePoint <= 0xe01ef)
)
}
function isEmojiModifier(codePoint: number) {
return codePoint >= 0x1f3fb && codePoint <= 0x1f3ff
}
function isEmojiTag(codePoint: number) {
return codePoint >= 0xe0020 && codePoint <= 0xe007f
}
function isAscii(character: string) {
return character.codePointAt(0)! <= 0x7f
}
function isAllowedZeroWidthNonJoiner(
characters: readonly string[],
characterIndex: number,
): boolean {
const previous = characters[characterIndex - 1]
const next = characters[characterIndex + 1]
if (!previous || !next || !LETTER_PATTERN.test(previous) || !LETTER_PATTERN.test(next)) {
return false
}
return !isAscii(previous) || !isAscii(next)
}
function findAdjacentEmojiCharacter(
characters: readonly string[],
start: number,
direction: -1 | 1,
): string | undefined {
for (let index = start; index >= 0 && index < characters.length; index += direction) {
const character = characters[index]
const codePoint = character.codePointAt(0)!
if (isVariationSelector(codePoint) || isEmojiModifier(codePoint)) continue
return character
}
return undefined
}
function isAllowedZeroWidthJoiner(characters: readonly string[], characterIndex: number): boolean {
const previous = findAdjacentEmojiCharacter(characters, characterIndex - 1, -1)
const next = findAdjacentEmojiCharacter(characters, characterIndex + 1, 1)
return (
previous !== undefined &&
next !== undefined &&
EXTENDED_PICTOGRAPHIC_PATTERN.test(previous) &&
EXTENDED_PICTOGRAPHIC_PATTERN.test(next)
)
}
function isAllowedVariationSelector(
characters: readonly string[],
characterIndex: number,
codePoint: number,
): boolean {
const previous = characters[characterIndex - 1]
if (!previous) return false
if (codePoint >= 0xfe00 && codePoint <= 0xfe0f) {
return EXTENDED_PICTOGRAPHIC_PATTERN.test(previous) || /^[0-9#*]$/u.test(previous)
}
return UNIFIED_IDEOGRAPH_PATTERN.test(previous)
}
function isAllowedEmojiTagSequence(characters: readonly string[], characterIndex: number) {
let start = characterIndex - 1
while (start >= 0 && isEmojiTag(characters[start].codePointAt(0)!)) start--
if (characters[start]?.codePointAt(0) !== 0x1f3f4) return false
let end = characterIndex
while (end < characters.length && isEmojiTag(characters[end].codePointAt(0)!)) end++
return characters[end - 1]?.codePointAt(0) === 0xe007f
}
function isPresentedAsEmoji(characters: readonly string[], characterIndex: number) {
const character = characters[characterIndex]
return (
EMOJI_PRESENTATION_PATTERN.test(character) ||
characters[characterIndex + 1]?.codePointAt(0) === 0xfe0f
)
}
function codePointLabel(codePoint: number) {
return `U+${codePoint.toString(16).toUpperCase().padStart(4, '0')}`
}
export function validateNonStandardText(
text: string,
options: NonStandardTextOptions = {},
): NonStandardTextResult {
const allowNewlines = options.allowNewlines ?? true
const allowTabs = options.allowTabs ?? true
const maxCombiningMarks =
options.maxCombiningMarksPerCharacter ?? DEFAULT_MAX_COMBINING_MARKS_PER_CHARACTER
if (!Number.isInteger(maxCombiningMarks) || maxCombiningMarks < 0) {
throw new Error('Maximum combining marks must be a non-negative integer')
}
const issues: NonStandardTextIssue[] = []
const counts = createCounts()
const characters = Array.from(text)
let utf16Index = 0
let hasBaseCharacter = false
let combiningMarkCount = 0
function addIssue(
kind: NonStandardTextIssueKind,
character: string,
codePoint: number,
index: number,
) {
issues.push({
kind,
character,
codePoint: codePointLabel(codePoint),
index,
})
counts[kind]++
}
for (let characterIndex = 0; characterIndex < characters.length; characterIndex++) {
const character = characters[characterIndex]
const codePoint = character.codePointAt(0)!
const currentIndex = utf16Index
utf16Index += character.length
if (codePoint >= 0xd800 && codePoint <= 0xdfff) {
addIssue('surrogate', character, codePoint, currentIndex)
hasBaseCharacter = false
combiningMarkCount = 0
continue
}
if (PRIVATE_USE_PATTERN.test(character)) {
addIssue('private-use', character, codePoint, currentIndex)
} else if (UNASSIGNED_PATTERN.test(character)) {
addIssue('unassigned', character, codePoint, currentIndex)
}
if (CONTROL_PATTERN.test(character)) {
const allowedNewline = allowNewlines && (character === '\n' || character === '\r')
const allowedTab = allowTabs && character === '\t'
if (!allowedNewline && !allowedTab) {
addIssue('control', character, codePoint, currentIndex)
}
hasBaseCharacter = false
combiningMarkCount = 0
continue
}
if (FORMAT_PATTERN.test(character)) {
const allowed =
codePoint === 0x200b ||
(codePoint === 0x200c && isAllowedZeroWidthNonJoiner(characters, characterIndex)) ||
(codePoint === 0x200d && isAllowedZeroWidthJoiner(characters, characterIndex)) ||
(isEmojiTag(codePoint) && isAllowedEmojiTagSequence(characters, characterIndex))
if (!allowed) addIssue('invisible', character, codePoint, currentIndex)
hasBaseCharacter = false
combiningMarkCount = 0
continue
}
if (MARK_PATTERN.test(character)) {
if (isVariationSelector(codePoint)) {
if (!isAllowedVariationSelector(characters, characterIndex, codePoint)) {
addIssue('invisible', character, codePoint, currentIndex)
}
continue
}
combiningMarkCount++
if (!hasBaseCharacter || combiningMarkCount > maxCombiningMarks) {
addIssue('zalgo', character, codePoint, currentIndex)
}
continue
}
combiningMarkCount = 0
hasBaseCharacter = !/^\s$/u.test(character)
if (
!ALLOWED_FANCY_CODE_POINTS.has(codePoint) &&
isInRanges(codePoint, FANCY_RANGES) &&
!isPresentedAsEmoji(characters, characterIndex)
) {
addIssue('fancy', character, codePoint, currentIndex)
}
}
return {
valid: issues.length === 0,
issues,
counts,
}
}
@@ -1,158 +0,0 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { getNonStandardTextRatio, validateNonStandardText } from './index.ts'
test('accepts ordinary multilingual text and punctuation', () => {
const result = validateNonStandardText(
'Hello™, “world”! Français — Русский — العربية — 中文 — 日本語',
)
assert.equal(result.valid, true)
assert.deepEqual(result.issues, [])
})
test('accepts Chinese text with fullwidth punctuation', () => {
const result = validateNonStandardText(
'这是一个中文项目,支持简体和繁體中文!请查看说明:性能、兼容性(支持 1.21)。',
)
assert.equal(result.valid, true)
assert.deepEqual(result.issues, [])
})
test('accepts composed and normally decomposed accents', () => {
assert.equal(validateNonStandardText('café').valid, true)
assert.equal(validateNonStandardText('cafe\u0301').valid, true)
assert.equal(validateNonStandardText('a\u0301\u0327').valid, true)
})
test('detects excessive and leading combining marks as zalgo text', () => {
const excessive = validateNonStandardText('a\u0301\u0327\u0308')
const leading = validateNonStandardText('\u0301text')
assert.equal(excessive.valid, false)
assert.equal(excessive.counts.zalgo, 1)
assert.equal(excessive.issues[0].index, 3)
assert.equal(leading.counts.zalgo, 1)
})
test('supports a custom combining-mark threshold', () => {
assert.equal(
validateNonStandardText('a\u0301\u0327', {
maxCombiningMarksPerCharacter: 1,
}).counts.zalgo,
1,
)
assert.throws(
() => validateNonStandardText('text', { maxCombiningMarksPerCharacter: -1 }),
/non-negative integer/,
)
})
test('detects common fancy alphabets and presentation forms', () => {
const result = validateNonStandardText('𝐇 Ⓗ ʰ ff')
assert.equal(result.valid, false)
assert.equal(result.counts.fancy, 7)
assert.deepEqual(
result.issues.map(({ codePoint }) => codePoint),
['U+1D407', 'U+24BD', 'U+02B0', 'U+210C', 'U+FF48', 'U+FF11', 'U+FB00'],
)
})
test('allows selected decorative characters from fancy ranges', () => {
assert.equal(validateNonStandardText('˖˗˘˙˚˛˜˝⁺⁻₊₋℠ℹ⊹✦').valid, true)
})
test('allows ordinary emoji and valid emoji joiner sequences', () => {
assert.equal(validateNonStandardText('Hello 👋🏽').valid, true)
assert.equal(validateNonStandardText('Family: 👨‍👩‍👧‍👦').valid, true)
assert.equal(validateNonStandardText('Developer: 🧑🏽‍💻').valid, true)
assert.equal(validateNonStandardText('Heart: ❤️').valid, true)
assert.equal(validateNonStandardText('Information: ️').valid, true)
assert.equal(validateNonStandardText('A button: 🅰️').valid, true)
assert.equal(validateNonStandardText('Scotland: 🏴󠁧󠁢󠁳󠁣󠁴󠁿').valid, true)
})
test('allows zero-width spaces used as formatting residue', () => {
const result = validateNonStandardText(
'Clan System: Bank - Ranks - Languages \u200B\u200B- Vault - Management',
)
assert.equal(result.valid, true)
assert.deepEqual(result.issues, [])
})
test('detects suspicious invisible and directional characters', () => {
const result = validateNonStandardText('ab\u2060cd\u202Eef\u2063gh f\uFE0F')
assert.equal(result.counts.invisible, 4)
assert.deepEqual(
result.issues.map(({ codePoint }) => codePoint),
['U+2060', 'U+202E', 'U+2063', 'U+FE0F'],
)
})
test('allows contextual non-joiners but catches ASCII separator evasion', () => {
assert.equal(validateNonStandardText('می‌خواهم').valid, true)
assert.equal(validateNonStandardText('fuck').counts.invisible, 1)
assert.equal(validateNonStandardText('ab').counts.invisible, 1)
})
test('allows newlines and tabs by default and can reject them', () => {
assert.equal(validateNonStandardText('line one\n\tline two').valid, true)
const result = validateNonStandardText('line one\n\tline two', {
allowNewlines: false,
allowTabs: false,
})
assert.equal(result.counts.control, 2)
})
test('detects other disallowed control characters', () => {
const result = validateNonStandardText(`hello\u0000world`)
assert.equal(result.counts.control, 1)
assert.equal(result.issues[0].codePoint, 'U+0000')
})
test('detects private-use, unassigned, and lone surrogate code points', () => {
const privateUse = validateNonStandardText('\uE000')
const unassigned = validateNonStandardText('\uFDD0')
const surrogate = validateNonStandardText('\uD800')
assert.equal(privateUse.counts['private-use'], 1)
assert.equal(unassigned.counts.unassigned, 1)
assert.equal(surrogate.counts.surrogate, 1)
})
test('reports UTF-16 indexes consistently around astral characters', () => {
const result = validateNonStandardText('🙂\u2060text')
assert.equal(result.issues[0].index, 2)
})
test('reports multiple issue categories in source order', () => {
const result = validateNonStandardText('𝐀\u2060\u0000')
assert.deepEqual(
result.issues.map(({ kind }) => kind),
['fancy', 'invisible', 'control'],
)
})
test('calculates the ratio of non-standard Unicode characters', () => {
const belowFivePercent = '𝐀'.concat('a'.repeat(20))
const exactlyFivePercent = '𝐀'.concat('a'.repeat(19))
assert.equal(
getNonStandardTextRatio(belowFivePercent, validateNonStandardText(belowFivePercent)),
1 / 21,
)
assert.equal(
getNonStandardTextRatio(exactlyFivePercent, validateNonStandardText(exactlyFivePercent)),
0.05,
)
assert.equal(getNonStandardTextRatio('', validateNonStandardText('')), 0)
})
@@ -1,414 +0,0 @@
import {
collapseDuplicatesTransformer,
parseRawPattern,
RegExpMatcher,
resolveConfusablesTransformer,
resolveLeetSpeakTransformer,
skipNonAlphabeticTransformer,
toAsciiLowerCaseTransformer,
} from 'obscenity'
export type ProfanityKind = 'profanity' | 'slur'
export interface ProfanityPattern {
kind: ProfanityKind
}
export interface ProfanityConfig {
patterns: Readonly<Record<string, ProfanityPattern>>
allowlist?: readonly string[]
}
export interface ProfanityMatch {
kind: ProfanityKind
term: string
rawText: string
start: number
end: number
}
export interface ProfanityResult {
valid: boolean
profanityCount: number
slurCount: number
firstMatch?: ProfanityMatch
matches: ProfanityMatch[]
}
export interface ProfanityValidator {
findFirst(text: string): ProfanityMatch | undefined
findAll(text: string): ProfanityMatch[]
validate(text: string): ProfanityResult
}
const TERMS = [
'anuslick',
'arsehol',
'arselick',
'asslick',
'arsch',
'asshol',
'auschwitz',
'beaner',
'bestiality',
'baise',
'bakachon',
'bakatyon',
'bastard',
'bitch',
'btch',
'biatch',
'bussy',
'blowjob',
'blowme',
'bukakke',
'buttplug',
'buttchug',
'butagorosi',
'cagada',
'caralho',
'cameljockey',
'castrate',
'cazzo',
'ceemen',
'chankoro',
'chink',
'chingchong',
'choad',
'chode',
'chlamydia',
'clit',
'clitoris',
'cock',
'coon',
'cocain',
'coitus',
'cottonpic',
'cottonpik',
'cum',
'cunt',
'cvnt',
'cunny',
'cunnie',
'csam',
'cyka',
'darkie',
'dick',
'dildo',
'douchebag',
'dyke',
'downie',
'dumbass',
'ejaculate',
'fag',
'feck',
'fellate',
'fellatio',
'felch',
'fuck',
'fvck',
'fxck',
'fack',
'fzck',
'fck',
'fudgepacker',
'flange',
'gestapo',
'gook',
'horny',
'hooker',
'hitler',
'incest',
'jap',
'jizz',
'jigabo',
'junglebunny',
'kkk',
'kike',
'klux',
'kluklux',
'klukluxklan',
'koon',
'lickmy',
'masturbat',
'molest',
'muff',
'nazi',
'nigger',
'nigga',
'niqa',
'niqqa',
'niggu',
'niqqu',
'niggr',
'niglet',
'nignog',
'paki',
'penis',
'porn',
'prostitut',
'pube',
'pussie',
'pussy',
'raghead',
'rape',
'rapist',
'retard',
'rimjob',
'shit',
'slut',
'spunk',
'suckmy',
'sodom',
'semen',
'teensex',
'tittie',
'titty',
'trannie',
'tranny',
'vagina',
'wank',
'wetback',
'whore',
'whitepower',
'fondle',
'minestorm',
'kissmy',
'blowmy',
'jelqing',
'dafuq',
] as const
const SLUR_TERMS = new Set([
'beaner',
'cameljockey',
'chankoro',
'chink',
'chingchong',
'coon',
'cottonpic',
'cottonpik',
'darkie',
'downie',
'dyke',
'fag',
'gook',
'jap',
'jigabo',
'junglebunny',
'kike',
'koon',
'nigg',
'niqa',
'nigga',
'niqqa',
'niggu',
'niqqu',
'niggr',
'nigger',
'niglet',
'nignog',
'paki',
'raghead',
'retard',
'trannie',
'tranny',
'wetback',
])
export const DEFAULT_PROFANITY_PATTERNS: Readonly<Record<string, ProfanityPattern>> =
Object.fromEntries(
TERMS.map((term) => {
const kind: ProfanityKind = SLUR_TERMS.has(term) ? 'slur' : 'profanity'
return [term, { kind }] as const
}),
)
export const DEFAULT_PROFANITY_ALLOWLIST = ['Кооп'] as const
export const DEFAULT_PROFANITY_CONFIG: ProfanityConfig = {
patterns: DEFAULT_PROFANITY_PATTERNS,
allowlist: DEFAULT_PROFANITY_ALLOWLIST,
}
function getDuplicateThresholds(terms: readonly string[]): Map<string, number> {
const thresholds = new Map<string, number>()
for (const term of terms) {
let runLength = 0
let previousCharacter = ''
for (const character of term) {
runLength = character === previousCharacter ? runLength + 1 : 1
previousCharacter = character
thresholds.set(character, Math.max(thresholds.get(character) ?? 1, runLength))
}
}
return thresholds
}
function isWordCharacter(character: string | undefined): boolean {
return character !== undefined && /^[\p{L}\p{M}\p{N}_]$/u.test(character)
}
const LEET_SPEAK_CHARACTERS = new Set(['@', '(', '|', '!', '/', '$'])
function isObfuscatedWordCharacter(character: string): boolean {
return isWordCharacter(character) || LEET_SPEAK_CHARACTERS.has(character)
}
function isCharacterByCharacterObfuscation(text: string): boolean {
const chunkLengths: number[] = []
let currentChunkLength = 0
let hasInvisibleSeparator = false
let hasVisibleSeparator = false
for (const character of text) {
if (isObfuscatedWordCharacter(character)) {
currentChunkLength++
} else {
if (/^\p{Cf}$/u.test(character)) {
hasInvisibleSeparator = true
} else {
hasVisibleSeparator = true
}
if (currentChunkLength > 0) {
chunkLengths.push(currentChunkLength)
currentChunkLength = 0
}
}
}
if (currentChunkLength > 0) chunkLengths.push(currentChunkLength)
return (
chunkLengths.length > 1 &&
((hasInvisibleSeparator && !hasVisibleSeparator) ||
chunkLengths.every((length) => length === 1))
)
}
function getCharacterBefore(text: string, index: number): string | undefined {
if (index <= 0) return undefined
const codePoint = text.codePointAt(index - 1)
if (codePoint === undefined) return undefined
if (codePoint >= 0xdc00 && codePoint <= 0xdfff && index > 1) {
return text.slice(index - 2, index)
}
return text[index - 1]
}
function getCharacterAt(text: string, index: number): string | undefined {
const codePoint = text.codePointAt(index)
return codePoint === undefined ? undefined : String.fromCodePoint(codePoint)
}
function isWholeWordMatch(text: string, start: number, end: number): boolean {
return (
!isWordCharacter(getCharacterBefore(text, start)) && !isWordCharacter(getCharacterAt(text, end))
)
}
export function createProfanityValidator(
config: ProfanityConfig = DEFAULT_PROFANITY_CONFIG,
): ProfanityValidator {
const allowlist = new Set(config.allowlist?.map((term) => term.normalize('NFC').toLowerCase()))
const entries = Object.entries(config.patterns).map(([rawTerm, pattern]) => {
const term = rawTerm.toLowerCase()
if (!term || !/^[a-z]+$/.test(term)) {
throw new Error(`Profanity term must contain only ASCII letters: ${rawTerm}`)
}
return { kind: pattern.kind, term }
})
const blacklistedTerms = entries.map(({ term }, id) => ({ id, pattern: parseRawPattern(term) }))
const baseTransformers = [
resolveConfusablesTransformer(),
resolveLeetSpeakTransformer(),
toAsciiLowerCaseTransformer(),
]
const duplicateTransformer = () =>
collapseDuplicatesTransformer({
customThresholds: getDuplicateThresholds(entries.map(({ term }) => term)),
})
const strictMatcher = new RegExpMatcher({
blacklistedTerms,
blacklistMatcherTransformers: [...baseTransformers, duplicateTransformer()],
})
const separatorMatcher = new RegExpMatcher({
blacklistedTerms,
blacklistMatcherTransformers: [
...baseTransformers,
skipNonAlphabeticTransformer(),
duplicateTransformer(),
],
})
function findAll(text: string): ProfanityMatch[] {
const matches: ProfanityMatch[] = []
const strictMatches = new Set(
[...strictMatcher.getAllMatches(text, true)].map(
(match) => `${match.termId}:${match.startIndex}:${match.endIndex}`,
),
)
for (const match of separatorMatcher.getAllMatches(text, true)) {
const profanityPattern = entries[match.termId]
const end = match.endIndex + 1
const rawText = text.slice(match.startIndex, end)
const matchKey = `${match.termId}:${match.startIndex}:${match.endIndex}`
if (
!profanityPattern ||
allowlist.has(rawText.normalize('NFC').toLowerCase()) ||
(!strictMatches.has(matchKey) && !isCharacterByCharacterObfuscation(rawText)) ||
!isWholeWordMatch(text, match.startIndex, end) ||
match.startIndex < (matches.at(-1)?.end ?? 0)
) {
continue
}
matches.push({
...profanityPattern,
rawText,
start: match.startIndex,
end,
})
}
return matches
}
function findFirst(text: string): ProfanityMatch | undefined {
return findAll(text)[0]
}
function validate(text: string): ProfanityResult {
const matches = findAll(text)
const profanityCount = matches.filter((match) => match.kind === 'profanity').length
const slurCount = matches.length - profanityCount
return {
valid: matches.length === 0,
profanityCount,
slurCount,
firstMatch: matches[0],
matches,
}
}
return {
findFirst,
findAll,
validate,
}
}
export const profanityValidator = createProfanityValidator()
export function validateProfanity(text: string): ProfanityResult {
return profanityValidator.validate(text)
}
@@ -1,130 +0,0 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { createProfanityValidator, validateProfanity } from './index.ts'
const blockedForms = [
['normal form', 'fuck'],
['capitalization', 'FUCK'],
['Unicode variants', 'fuck'],
['zero-width characters', 'f\u200Buck'],
['leetspeak', '$h!t'],
['period separators', 'f.u.c.k'],
['space separators', 'f u c k'],
['repeated letters', 'fuuuuuck'],
] as const
for (const [form, input] of blockedForms) {
test(`blocks ${form}`, () => {
const result = validateProfanity(input)
assert.equal(result.valid, false)
assert.equal(result.firstMatch?.rawText, input)
})
}
test('matches whole words without term exceptions', () => {
const validator = createProfanityValidator({
patterns: {
bad: { kind: 'profanity' },
},
})
assert.equal(validator.findFirst('not bad word')?.term, 'bad')
assert.equal(validator.findFirst('notbadword'), undefined)
})
test('uses the whole-word match when one configured term prefixes another', () => {
const validator = createProfanityValidator({
patterns: {
bad: { kind: 'profanity' },
badword: { kind: 'profanity' },
},
})
assert.equal(validator.findFirst('badword')?.term, 'badword')
})
test('does not join ordinary words or match inside larger words', () => {
assert.equal(validateProfanity('pause menu').valid, true)
assert.equal(validateProfanity('accumulate').valid, true)
assert.equal(validateProfanity('cum').valid, false)
assert.equal(validateProfanity('s e m e n').valid, false)
})
test('does not join multi-character chunks into a slur', () => {
assert.equal(validateProfanity('6000 -> OK').valid, true)
assert.equal(validateProfanity('6000 OK').valid, true)
})
test('rejects any uncensored configured profanity', () => {
assert.equal(validateProfanity('A clean project').valid, true)
assert.equal(validateProfanity('This is shit').valid, false)
})
test('allows redacted profanity when the removed letters cannot reconstruct a term', () => {
assert.equal(validateProfanity('f**k').valid, true)
assert.equal(validateProfanity('f**k works in titles, summaries, and descriptions').valid, true)
assert.equal(validateProfanity('f.u.c.k').valid, false)
})
test('allows exact allowlisted terms case-insensitively', () => {
const validator = createProfanityValidator({
patterns: {
koon: { kind: 'slur' },
},
allowlist: ['Кооп'],
})
assert.equal(validator.validate('Кооп').valid, true)
assert.equal(validator.validate('кооп').valid, true)
assert.equal(validator.validate('К.о.о.п').valid, false)
assert.equal(validator.validate('koon').valid, false)
})
test('allows default allowlisted terms', () => {
assert.equal(validateProfanity('Кооп').valid, true)
assert.equal(validateProfanity('кооп').valid, true)
assert.equal(validateProfanity('A Кооп project').valid, true)
})
test('classifies slurs separately from other profanity', () => {
const validator = createProfanityValidator({
patterns: {
forbidden: { kind: 'slur' },
},
})
const result = validator.validate('FORBIDDEN')
assert.equal(result.valid, false)
assert.equal(result.profanityCount, 0)
assert.equal(result.slurCount, 1)
})
test('returns non-overlapping matches and original input offsets', () => {
const validator = createProfanityValidator({
patterns: {
bad: { kind: 'profanity' },
},
})
assert.deepEqual(
validator.findAll('b.a.d bad').map(({ start, end }) => [start, end]),
[
[0, 5],
[6, 9],
],
)
})
test('rejects invalid configuration', () => {
assert.throws(
() =>
createProfanityValidator({
patterns: {
'not sanitized': { kind: 'profanity' },
},
}),
/term must contain only ASCII letters/,
)
})
@@ -1,113 +0,0 @@
export type SpamMatchKind = 'character' | 'word' | 'phrase'
export interface SpamMatch {
kind: SpamMatchKind
value: string
}
export interface SpamResult {
valid: boolean
firstMatch?: SpamMatch
}
export const MIN_REPEATED_CHARACTER_COUNT = 8
export const MIN_REPEATED_WORD_COUNT = 4
export const MIN_REPEATED_PHRASE_COUNT = 3
export const MAX_REPEATED_PHRASE_WORDS = 8
const REPEATABLE_CHARACTER_PATTERN = /\S/u
const WORD_PATTERN = /[\p{L}\p{M}\p{N}]+/gu
function findRepeatedCharacter(text: string): SpamMatch | undefined {
let previousCharacter: string | undefined
let repeatedCount = 0
for (const character of text.normalize('NFC')) {
const normalizedCharacter = character.toLowerCase()
if (
REPEATABLE_CHARACTER_PATTERN.test(normalizedCharacter) &&
normalizedCharacter === previousCharacter
) {
repeatedCount++
} else {
previousCharacter = normalizedCharacter
repeatedCount = REPEATABLE_CHARACTER_PATTERN.test(normalizedCharacter) ? 1 : 0
}
if (repeatedCount >= MIN_REPEATED_CHARACTER_COUNT) {
return { kind: 'character', value: character.repeat(repeatedCount) }
}
}
return undefined
}
function getWords(text: string): string[] {
return [...text.normalize('NFC').toLowerCase().matchAll(WORD_PATTERN)].map((match) => match[0])
}
function findRepeatedWord(words: readonly string[]): SpamMatch | undefined {
let repeatedCount = 1
for (let index = 1; index < words.length; index++) {
repeatedCount = words[index] === words[index - 1] ? repeatedCount + 1 : 1
if (repeatedCount >= MIN_REPEATED_WORD_COUNT) {
return { kind: 'word', value: words[index] }
}
}
return undefined
}
function phrasesMatch(
words: readonly string[],
firstStart: number,
secondStart: number,
size: number,
) {
for (let offset = 0; offset < size; offset++) {
if (words[firstStart + offset] !== words[secondStart + offset]) return false
}
return true
}
function findRepeatedPhrase(words: readonly string[]): SpamMatch | undefined {
const maxPhraseWords = Math.min(
MAX_REPEATED_PHRASE_WORDS,
Math.floor(words.length / MIN_REPEATED_PHRASE_COUNT),
)
for (let phraseWords = maxPhraseWords; phraseWords >= 2; phraseWords--) {
const repeatedWords = phraseWords * MIN_REPEATED_PHRASE_COUNT
for (let start = 0; start + repeatedWords <= words.length; start++) {
let matches = true
for (let repetition = 1; repetition < MIN_REPEATED_PHRASE_COUNT; repetition++) {
if (!phrasesMatch(words, start, start + repetition * phraseWords, phraseWords)) {
matches = false
break
}
}
if (matches) {
return { kind: 'phrase', value: words.slice(start, start + phraseWords).join(' ') }
}
}
}
return undefined
}
/**
The spam validator checks normalized, readable description text in this order:
Characters: the same non-whitespace character repeated 8 times consecutively, e.g. aaaaaaaa.
Words: the same word repeated 4 times consecutively, case-insensitively, e.g. Great great GREAT great.
Phrases: a 28 word phrase repeated 3 times consecutively. Punctuation and capitalization are ignored, so best project, best project! BEST PROJECT is rejected.
*/
export function validateSpam(text: string): SpamResult {
const words = getWords(text)
const firstMatch =
findRepeatedCharacter(text) ?? findRepeatedWord(words) ?? findRepeatedPhrase(words)
return firstMatch ? { valid: false, firstMatch } : { valid: true }
}
@@ -1,36 +0,0 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { validateSpam } from './index.ts'
test('allows ordinary prose', () => {
assert.equal(
validateSpam('This project adds a configurable compass for exploring large worlds.').valid,
true,
)
})
test('detects repeated characters', () => {
assert.deepEqual(validateSpam('aaaaaaaa').firstMatch, {
kind: 'character',
value: 'aaaaaaaa',
})
})
test('detects repeated words case-insensitively', () => {
assert.deepEqual(validateSpam('Great great GREAT great').firstMatch, {
kind: 'word',
value: 'great',
})
})
test('detects repeated phrases across punctuation', () => {
assert.deepEqual(validateSpam('best project, best project! BEST PROJECT').firstMatch, {
kind: 'phrase',
value: 'best project',
})
})
test('allows repetition below the spam thresholds', () => {
assert.equal(validateSpam('so so so good good phrase here phrase here').valid, true)
})