mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 03:55:59 +00:00
feat: implement more project field validators
This commit is contained in:
@@ -15,12 +15,14 @@
|
||||
"@modrinth/assets": "workspace:*",
|
||||
"@modrinth/utils": "workspace:*",
|
||||
"@modrinth/api-client": "workspace:*",
|
||||
"linkify-it": "^5.0.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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,3 +22,4 @@ export * from './utils'
|
||||
export * from './validators/link-checks'
|
||||
export * from './validators/non-standard-text'
|
||||
export * from './validators/profanity'
|
||||
export * from './validators/project-fields'
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
"defaultMessage": "The wiki is disabled on this repository."
|
||||
},
|
||||
"nags.link.invalid-url": {
|
||||
"defaultMessage": "This is not a valid URL."
|
||||
"defaultMessage": "There's an invalid URL in the description."
|
||||
},
|
||||
"nags.link.license.url-mismatch": {
|
||||
"defaultMessage": "This link points to the {detected} license, but your project is set to {selected}."
|
||||
@@ -343,5 +343,26 @@
|
||||
},
|
||||
"nags.visit-links-settings.title": {
|
||||
"defaultMessage": "Visit links settings"
|
||||
},
|
||||
"project.text-validation.non-standard-text": {
|
||||
"defaultMessage": "Non-standard text characters are not allowed."
|
||||
},
|
||||
"project.text-validation.profanity": {
|
||||
"defaultMessage": "Profanity is not allowed."
|
||||
},
|
||||
"project.text-validation.slur": {
|
||||
"defaultMessage": "Slurs are not allowed."
|
||||
},
|
||||
"project.text-validation.summary-link": {
|
||||
"defaultMessage": "Links are not allowed in project summaries."
|
||||
},
|
||||
"project.text-validation.summary-matches-title": {
|
||||
"defaultMessage": "A project summary cannot be the same as its title."
|
||||
},
|
||||
"project.text-validation.title-game-version": {
|
||||
"defaultMessage": "Project titles cannot include the Minecraft version “{value}”."
|
||||
},
|
||||
"project.text-validation.title-loader": {
|
||||
"defaultMessage": "Project titles cannot include the loader “{value}”."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ function defineMessages<T extends Record<string, MessageDescriptor>>(descriptors
|
||||
export interface LinkCheckContext {
|
||||
url: string | undefined
|
||||
field: string
|
||||
generalContent?: boolean
|
||||
|
||||
[key: string]: unknown
|
||||
}
|
||||
@@ -226,7 +227,10 @@ async function matchNode(
|
||||
(child) => !isAsyncMatcher(child.when) && !child.isFallback,
|
||||
)
|
||||
const asyncChildren = node.childNodes.filter(
|
||||
(child) => isAsyncMatcher(child.when) && !child.isFallback,
|
||||
(child) =>
|
||||
isAsyncMatcher(child.when) &&
|
||||
!child.isFallback &&
|
||||
!(context.generalContent && hasFieldSpecificDescendant(child)),
|
||||
)
|
||||
const fallbackChildren = node.childNodes.filter((child) => child.isFallback)
|
||||
let expectedChild: LinkCheckNode | undefined
|
||||
@@ -272,7 +276,7 @@ const coreMessages = defineMessages({
|
||||
//TODO: we should probably just let you not provide https but backend currently requires it
|
||||
const invalidUrlMessage = defineMessage({
|
||||
id: 'nags.link.invalid-url',
|
||||
defaultMessage: 'This is not a valid URL.',
|
||||
defaultMessage: "There's an invalid URL in the description.",
|
||||
})
|
||||
|
||||
function validUrlPrefix(remaining: string): number | null {
|
||||
@@ -314,6 +318,13 @@ function cacheKey(context: LinkCheckContext): string {
|
||||
return JSON.stringify(context)
|
||||
}
|
||||
|
||||
function hasFieldSpecificDescendant(node: LinkCheckNode): boolean {
|
||||
return (
|
||||
(node.forMatchers?.length ?? 0) > 0 ||
|
||||
(node.childNodes?.some((child) => hasFieldSpecificDescendant(child)) ?? false)
|
||||
)
|
||||
}
|
||||
|
||||
async function checkLink(context: LinkCheckContext) {
|
||||
const url = context.url
|
||||
if (!url) return
|
||||
@@ -327,6 +338,10 @@ async function checkLink(context: LinkCheckContext) {
|
||||
|
||||
const found = await matchNode(rootNode, normalizedUrl, context, true)
|
||||
if (!found) {
|
||||
if (context.generalContent && validUrlPrefix(normalizedUrl) !== null) {
|
||||
cache.set(key, valid)
|
||||
return
|
||||
}
|
||||
cache.delete(key)
|
||||
return
|
||||
}
|
||||
@@ -336,6 +351,11 @@ async function checkLink(context: LinkCheckContext) {
|
||||
const applies = isLeaf && matched.forMatchers?.some((matcher) => matchesField(matcher, context))
|
||||
|
||||
if (!applies) {
|
||||
if (context.generalContent && hasFieldSpecificDescendant(matched)) {
|
||||
cache.set(key, valid)
|
||||
return
|
||||
}
|
||||
|
||||
const build = matched.unrecognizedSeverity === 'warn' ? warn : error
|
||||
|
||||
if (matched.unrecognizedMessage && isLeaf) {
|
||||
|
||||
@@ -36,6 +36,37 @@ test('rejects a recognized link used in the wrong field', async () => {
|
||||
assert.equal(getLinkCheckState(context)?.message?.id, 'nags.link.wrong-field')
|
||||
})
|
||||
|
||||
test('allows structured link types in general content', async () => {
|
||||
const context = {
|
||||
field: 'description',
|
||||
url: 'https://github.com/modrinth/code',
|
||||
generalContent: true,
|
||||
}
|
||||
|
||||
await checkLink(context)
|
||||
|
||||
assert.equal(getLinkCheckState(context)?.severity, 'valid')
|
||||
})
|
||||
|
||||
test('allows unrecognized valid links but keeps global restrictions in general content', async () => {
|
||||
const allowed = {
|
||||
field: 'description',
|
||||
url: 'https://docs.example.dev/project',
|
||||
generalContent: true,
|
||||
}
|
||||
const blocked = {
|
||||
field: 'description',
|
||||
url: 'https://bit.ly/project',
|
||||
generalContent: true,
|
||||
}
|
||||
|
||||
await checkLink(allowed)
|
||||
await checkLink(blocked)
|
||||
|
||||
assert.equal(getLinkCheckState(allowed)?.severity, 'valid')
|
||||
assert.equal(getLinkCheckState(blocked)?.severity, 'error')
|
||||
})
|
||||
|
||||
test('compares recognized license URLs with the selected license', async () => {
|
||||
const matching = {
|
||||
field: 'license',
|
||||
|
||||
@@ -29,6 +29,14 @@ export interface NonStandardTextOptions {
|
||||
|
||||
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],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { validateNonStandardText } from './index.ts'
|
||||
import { getNonStandardTextRatio, validateNonStandardText } from './index.ts'
|
||||
|
||||
test('accepts ordinary multilingual text and punctuation', () => {
|
||||
const result = validateNonStandardText(
|
||||
@@ -119,3 +119,18 @@ test('reports multiple issue categories in source order', () => {
|
||||
['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)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import LinkifyIt from 'linkify-it'
|
||||
|
||||
import { getNonStandardTextRatio, validateNonStandardText } from '../non-standard-text/index.ts'
|
||||
import { validateProfanity } from '../profanity/index.ts'
|
||||
|
||||
export interface ProjectFieldMessageDescriptor {
|
||||
id: string
|
||||
defaultMessage?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
function defineMessages<T extends Record<string, ProjectFieldMessageDescriptor>>(descriptors: T): T {
|
||||
return descriptors
|
||||
}
|
||||
|
||||
export interface ProjectTextValidationResult {
|
||||
severity: 'error'
|
||||
message: ProjectFieldMessageDescriptor
|
||||
values?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ProjectTextValidationOptions {
|
||||
nonStandardTextFailureThreshold?: number
|
||||
}
|
||||
|
||||
export const DESCRIPTION_NON_STANDARD_TEXT_FAILURE_THRESHOLD = 0.05
|
||||
|
||||
const messages = defineMessages({
|
||||
slur: {
|
||||
id: 'project.text-validation.slur',
|
||||
defaultMessage: 'Slurs are not allowed.',
|
||||
},
|
||||
profanity: {
|
||||
id: 'project.text-validation.profanity',
|
||||
defaultMessage: 'Profanity is not allowed.',
|
||||
},
|
||||
nonStandardText: {
|
||||
id: 'project.text-validation.non-standard-text',
|
||||
defaultMessage: 'Non-standard text characters are not allowed.',
|
||||
},
|
||||
titleGameVersion: {
|
||||
id: 'project.text-validation.title-game-version',
|
||||
defaultMessage: 'Project titles cannot include the Minecraft version “{value}”.',
|
||||
},
|
||||
titleLoader: {
|
||||
id: 'project.text-validation.title-loader',
|
||||
defaultMessage: 'Project titles cannot include the loader “{value}”.',
|
||||
},
|
||||
summaryLink: {
|
||||
id: 'project.text-validation.summary-link',
|
||||
defaultMessage: 'Links are not allowed in project summaries.',
|
||||
},
|
||||
summaryMatchesTitle: {
|
||||
id: 'project.text-validation.summary-matches-title',
|
||||
defaultMessage: 'A project summary cannot be the same as its title.',
|
||||
},
|
||||
})
|
||||
|
||||
const titleMetadataMessages = {
|
||||
'game-version': messages.titleGameVersion,
|
||||
loader: messages.titleLoader,
|
||||
}
|
||||
|
||||
export type ProjectTitleMetadataKind = 'game-version' | 'loader'
|
||||
|
||||
export interface ProjectTitleMetadata {
|
||||
gameVersions: readonly string[]
|
||||
loaders: readonly string[]
|
||||
}
|
||||
|
||||
export interface ProjectTitleMetadataMatch {
|
||||
kind: ProjectTitleMetadataKind
|
||||
value: string
|
||||
}
|
||||
|
||||
const linkify = new LinkifyIt({
|
||||
fuzzyEmail: false,
|
||||
fuzzyIP: true,
|
||||
fuzzyLink: true,
|
||||
})
|
||||
|
||||
function normalizeForSearch(value: string) {
|
||||
return value.normalize('NFC').toLowerCase()
|
||||
}
|
||||
|
||||
export function findProjectTitleMetadata(
|
||||
title: string,
|
||||
metadata: ProjectTitleMetadata,
|
||||
): ProjectTitleMetadataMatch | null {
|
||||
const normalizedTitle = normalizeForSearch(title)
|
||||
const groups: ReadonlyArray<readonly [ProjectTitleMetadataKind, readonly string[]]> = [
|
||||
['game-version', metadata.gameVersions],
|
||||
['loader', metadata.loaders],
|
||||
]
|
||||
|
||||
for (const [kind, values] of groups) {
|
||||
for (const value of values) {
|
||||
const normalizedValue = normalizeForSearch(value.trim())
|
||||
if (normalizedValue && normalizedTitle.includes(normalizedValue)) {
|
||||
return { kind, value }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function normalizeProjectFieldText(value: string) {
|
||||
return value.trim().normalize('NFC')
|
||||
}
|
||||
|
||||
export function projectSummaryMatchesTitle(summary: string, title: string) {
|
||||
const normalizedSummary = normalizeProjectFieldText(summary)
|
||||
const normalizedTitle = normalizeProjectFieldText(title)
|
||||
|
||||
return normalizedSummary.length > 0 && normalizedSummary === normalizedTitle
|
||||
}
|
||||
|
||||
export function extractProjectLinks(text: string) {
|
||||
const matches = linkify.match(text) ?? []
|
||||
return [...new Set(matches.map((match) => match.url))]
|
||||
}
|
||||
|
||||
export function containsProjectLinkOrIp(text: string) {
|
||||
return linkify.test(text)
|
||||
}
|
||||
|
||||
export function validateProjectText(
|
||||
text: string | null | undefined,
|
||||
options: ProjectTextValidationOptions = {},
|
||||
): ProjectTextValidationResult | null {
|
||||
if (!text) return null
|
||||
|
||||
const profanity = validateProfanity(text)
|
||||
if (profanity.slurCount > 0) {
|
||||
return { severity: 'error', message: messages.slur }
|
||||
}
|
||||
if (profanity.profanityCount > 0) {
|
||||
return { severity: 'error', message: messages.profanity }
|
||||
}
|
||||
|
||||
const nonStandardText = validateNonStandardText(text)
|
||||
const nonStandardTextFailureThreshold = options.nonStandardTextFailureThreshold ?? 0
|
||||
if (
|
||||
!nonStandardText.valid &&
|
||||
getNonStandardTextRatio(text, nonStandardText) >= nonStandardTextFailureThreshold
|
||||
) {
|
||||
return { severity: 'error', message: messages.nonStandardText }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function validateProjectTitle(
|
||||
text: string | null | undefined,
|
||||
metadata: ProjectTitleMetadata,
|
||||
): ProjectTextValidationResult | null {
|
||||
const textValidation = validateProjectText(text)
|
||||
if (textValidation || !text) return textValidation
|
||||
|
||||
const match = findProjectTitleMetadata(text, metadata)
|
||||
if (!match) return null
|
||||
|
||||
return {
|
||||
severity: 'error',
|
||||
message: titleMetadataMessages[match.kind],
|
||||
values: { value: match.value },
|
||||
}
|
||||
}
|
||||
|
||||
export function validateProjectSummary(
|
||||
summary: string | null | undefined,
|
||||
title: string | null | undefined,
|
||||
): ProjectTextValidationResult | null {
|
||||
const textValidation = validateProjectText(summary)
|
||||
if (textValidation || !summary) return textValidation
|
||||
|
||||
if (containsProjectLinkOrIp(summary)) {
|
||||
return { severity: 'error', message: messages.summaryLink }
|
||||
}
|
||||
|
||||
if (title && projectSummaryMatchesTitle(summary, title)) {
|
||||
return { severity: 'error', message: messages.summaryMatchesTitle }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function validateProjectDescription(
|
||||
description: string | null | undefined,
|
||||
): ProjectTextValidationResult | null {
|
||||
return validateProjectText(description, {
|
||||
nonStandardTextFailureThreshold: DESCRIPTION_NON_STANDARD_TEXT_FAILURE_THRESHOLD,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
containsProjectLinkOrIp,
|
||||
extractProjectLinks,
|
||||
findProjectTitleMetadata,
|
||||
projectSummaryMatchesTitle,
|
||||
validateProjectDescription,
|
||||
validateProjectSummary,
|
||||
validateProjectText,
|
||||
validateProjectTitle,
|
||||
} from './index.ts'
|
||||
|
||||
const metadata = {
|
||||
gameVersions: ['1.21.1'],
|
||||
loaders: ['fabric'],
|
||||
}
|
||||
|
||||
test('finds game versions and loaders in project titles', () => {
|
||||
assert.deepEqual(findProjectTitleMetadata('Tools for 1.21.1', metadata), {
|
||||
kind: 'game-version',
|
||||
value: '1.21.1',
|
||||
})
|
||||
assert.deepEqual(findProjectTitleMetadata('FABRIC Tools', metadata), {
|
||||
kind: 'loader',
|
||||
value: 'fabric',
|
||||
})
|
||||
assert.equal(findProjectTitleMetadata('Magical Tools', metadata), null)
|
||||
assert.equal(findProjectTitleMetadata('Ordinary Tools', metadata), null)
|
||||
})
|
||||
|
||||
test('compares summaries and titles after trimming and Unicode normalization', () => {
|
||||
assert.equal(projectSummaryMatchesTitle(' Caf\u00e9 ', 'Cafe\u0301'), true)
|
||||
assert.equal(projectSummaryMatchesTitle('Project summary', 'Project title'), false)
|
||||
assert.equal(projectSummaryMatchesTitle('', ''), false)
|
||||
})
|
||||
|
||||
test('detects links and IP addresses but not email addresses or game versions', () => {
|
||||
assert.equal(containsProjectLinkOrIp('Visit https://modrinth.com'), true)
|
||||
assert.equal(containsProjectLinkOrIp('Visit modrinth.com'), true)
|
||||
assert.equal(containsProjectLinkOrIp('Join 127.0.0.1:25565'), true)
|
||||
assert.equal(containsProjectLinkOrIp('Supports Minecraft 1.21.1'), false)
|
||||
assert.equal(containsProjectLinkOrIp('Contact hello@example.com'), false)
|
||||
})
|
||||
|
||||
test('extracts and deduplicates normalized links', () => {
|
||||
assert.deepEqual(
|
||||
extractProjectLinks(
|
||||
'Visit [Modrinth](https://modrinth.com) and example.net twice: example.net',
|
||||
),
|
||||
['https://modrinth.com', 'http://example.net'],
|
||||
)
|
||||
})
|
||||
|
||||
test('validates shared project text', () => {
|
||||
assert.equal(validateProjectText('An ordinary project'), null)
|
||||
assert.equal(
|
||||
validateProjectText('This project is shit')?.message.id,
|
||||
'project.text-validation.profanity',
|
||||
)
|
||||
assert.equal(
|
||||
validateProjectText('𝐅ancy project')?.message.id,
|
||||
'project.text-validation.non-standard-text',
|
||||
)
|
||||
})
|
||||
|
||||
test('validates project title metadata', () => {
|
||||
assert.deepEqual(validateProjectTitle('Fabric Tools', metadata), {
|
||||
severity: 'error',
|
||||
message: {
|
||||
id: 'project.text-validation.title-loader',
|
||||
defaultMessage: 'Project titles cannot include the loader “{value}”.',
|
||||
},
|
||||
values: { value: 'fabric' },
|
||||
})
|
||||
assert.equal(validateProjectTitle('Ordinary Tools', metadata), null)
|
||||
})
|
||||
|
||||
test('validates project summaries', () => {
|
||||
assert.equal(
|
||||
validateProjectSummary('Visit modrinth.com', 'Project title')?.message.id,
|
||||
'project.text-validation.summary-link',
|
||||
)
|
||||
assert.equal(
|
||||
validateProjectSummary(' Caf\u00e9 ', 'Cafe\u0301')?.message.id,
|
||||
'project.text-validation.summary-matches-title',
|
||||
)
|
||||
assert.equal(validateProjectSummary('Project summary', 'Project title'), null)
|
||||
})
|
||||
|
||||
test('allows sparse non-standard text in descriptions but rejects it at the threshold', () => {
|
||||
const belowFivePercent = '𝐀'.concat('a'.repeat(20))
|
||||
const exactlyFivePercent = '𝐀'.concat('a'.repeat(19))
|
||||
|
||||
assert.equal(validateProjectDescription(belowFivePercent), null)
|
||||
assert.equal(
|
||||
validateProjectDescription(exactlyFivePercent)?.message.id,
|
||||
'project.text-validation.non-standard-text',
|
||||
)
|
||||
})
|
||||
@@ -1,3 +1,6 @@
|
||||
{
|
||||
"extends": "@modrinth/tooling-config/typescript/vue.json"
|
||||
"extends": "@modrinth/tooling-config/typescript/vue.json",
|
||||
"compilerOptions": {
|
||||
"allowImportingTsExtensions": true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user