feat: change links in summary to be a blocking error and combined with formatting nag, banned links in description are also blocking

This commit is contained in:
tdgao
2026-08-26 18:16:55 -06:00
parent ed859272d3
commit 5dcd66f27f
12 changed files with 349 additions and 87 deletions
@@ -46,6 +46,10 @@ export function useProjectDescriptionValidation(
linkValidation.value = null
if (import.meta.server) return
if (validateProjectDescription(text).some(({ code }) => code === 'text-banned-link')) {
pending.value = false
return
}
const links = extractProjectLinks(text ?? '')
if (links.length === 0) {
+42 -28
View File
@@ -2,6 +2,10 @@ import { defineMessage, formatProjectTypeSentence, useVIntl } from '@modrinth/ui
import type { Nag, NagContext } from '../../types/nags'
import { licenseRequiresSource, notSourceAsDistributed } from '../../utils'
import {
getBlockedProjectContentLink,
PROJECT_CONTENT_LINK_SHORTENERS,
} from '../../validators/project-links'
export const commonLinkDomains = {
source: [
@@ -45,10 +49,10 @@ export const commonLinkDomains = {
'example.com',
't.me',
],
linkShorteners: ['bit.ly', 'adf.ly', 'tinyurl.com', 'short.io', 'is.gd'],
linkShorteners: PROJECT_CONTENT_LINK_SHORTENERS,
}
export function isCommonUrl(url: string | null, commonDomains: string[]): boolean {
export function isCommonUrl(url: string | null, commonDomains: readonly string[]): boolean {
if (url === null || url === '') return true
try {
const domain = new URL(url).hostname.toLowerCase()
@@ -58,7 +62,7 @@ export function isCommonUrl(url: string | null, commonDomains: string[]): boolea
}
}
export function isCommonUrlOfType(url: string | null, commonDomains: string[]): boolean {
export function isCommonUrlOfType(url: string | null, commonDomains: readonly string[]): boolean {
if (url === null || url === '') return false
return isCommonUrl(url, commonDomains)
}
@@ -75,6 +79,26 @@ export function isUncommonLicenseUrl(url: string | null): boolean {
return isCommonUrlOfType(url, commonLinkDomains.licenseBlocklist)
}
export function findBlockedProjectExternalLink(context: Pick<NagContext, 'project' | 'projectV3'>) {
const urls = [
context.project.source_url,
context.project.issues_url,
context.project.wiki_url,
context.project.discord_url,
context.project.license.url,
...(context.project.donation_urls ?? []).map(({ url }) => url),
...Object.values(context.projectV3?.link_urls ?? {}).map(({ url }) => url),
]
for (const url of urls) {
if (!url) continue
const blockedLink = getBlockedProjectContentLink(url)
if (blockedLink) return blockedLink
}
return null
}
export const linksNags: Nag[] = [
{
id: 'add-links',
@@ -210,36 +234,26 @@ export const linksNags: Nag[] = [
},
},
{
id: 'link-shortener-usage',
id: 'banned-link-usage',
title: defineMessage({
id: 'nags.link-shortener-usage.title',
defaultMessage: "Don't use link shorteners",
id: 'nags.banned-link-usage.title',
defaultMessage: 'Remove prohibited links',
}),
description: defineMessage({
id: 'nags.link-shortener-usage.description',
defaultMessage:
'Use of link shorteners or other methods to obscure where a link may lead in your external links or license link is prohibited, please only use appropriate full length links.',
}),
status: 'required',
shouldShow: (context: NagContext) => {
if (context.project.donation_urls) {
for (const donation of context.project.donation_urls) {
if (isLinkShortener(donation.url ?? null)) {
return true
}
}
}
description: (context: NagContext) => {
const blockedLink = findBlockedProjectExternalLink(context)
if (!blockedLink) return ''
return (
isLinkShortener(context.project.source_url ?? null) ||
isLinkShortener(context.project.issues_url ?? null) ||
isLinkShortener(context.project.wiki_url ?? null) ||
isLinkShortener(context.project.discord_url ?? null) ||
isLinkShortener(context.projectV3?.link_urls?.site?.url ?? null) ||
isLinkShortener(context.projectV3?.link_urls?.store?.url ?? null) ||
Boolean(context.project.license.url && isLinkShortener(context.project.license.url ?? null))
const { formatMessage } = useVIntl()
return formatMessage(
defineMessage({
id: 'nags.banned-link-usage.description',
defaultMessage: '“{url}” is not allowed in project links.',
}),
blockedLink,
)
},
status: 'required',
shouldShow: (context: NagContext) => findBlockedProjectExternalLink(context) !== null,
},
{
id: 'invalid-license-url',
@@ -18,15 +18,15 @@ const summaryErrorCodes: readonly ProjectTextValidationCode[] = [
'text-slur',
'text-profanity',
'text-non-standard',
]
const summaryWarningCodes: readonly ProjectTextValidationCode[] = [
'text-banned-link',
'summary-link',
'summary-matches-title',
]
const summaryWarningCodes: readonly ProjectTextValidationCode[] = ['summary-matches-title']
const descriptionErrorCodes: readonly ProjectTextValidationCode[] = [
'text-slur',
'text-profanity',
'text-non-standard',
'text-banned-link',
]
function getFirstFailure(
+1
View File
@@ -23,4 +23,5 @@ export * from './validators/link-checks'
export * from './validators/non-standard-text'
export * from './validators/profanity'
export * from './validators/project-fields'
export * from './validators/project-links'
export * from './validators/project-validation'
@@ -47,6 +47,12 @@
"nags.all-tags-selected.title": {
"defaultMessage": "Select accurate tags"
},
"nags.banned-link-usage.description": {
"defaultMessage": "“{url}” is not allowed in project links."
},
"nags.banned-link-usage.title": {
"defaultMessage": "Remove prohibited links"
},
"nags.check-disclosures.description": {
"defaultMessage": "Make sure users are aware of any important details by filling in content disclosures that apply to your {type}."
},
@@ -128,12 +134,6 @@
"nags.invalid-project-summary.title": {
"defaultMessage": "Fix the project summary"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "Use of link shorteners or other methods to obscure where a link may lead in your external links or license link is prohibited, please only use appropriate full length links."
},
"nags.link-shortener-usage.title": {
"defaultMessage": "Don't use link shorteners"
},
"nags.link.discord.channel": {
"defaultMessage": "This is a link to a Discord channel, not a server invite."
},
@@ -297,7 +297,7 @@
"defaultMessage": "Visit versions settings"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "Your summary should not contain formatting, line breaks, or special characters, since the summary will only display plain text."
"defaultMessage": "Your summary should not contain formatting, line breaks, special characters, or links. The summary only displays plain text."
},
"nags.summary-special-formatting.title": {
"defaultMessage": "Clean up the summary"
@@ -350,6 +350,9 @@
"nags.visit-links-settings.title": {
"defaultMessage": "Visit links settings"
},
"project.text-validation.banned-link": {
"defaultMessage": "“{url}” is not allowed in project summaries or descriptions."
},
"project.text-validation.description-profanity": {
"defaultMessage": "Excessive profanity is not allowed. Detected: {values}"
},
@@ -362,9 +365,6 @@
"project.text-validation.slur": {
"defaultMessage": "The detected slur “{value}” is not allowed."
},
"project.text-validation.summary-link": {
"defaultMessage": "Links should not be included in project summaries."
},
"project.text-validation.summary-matches-title": {
"defaultMessage": "A project summary should not be the same as its title."
},
@@ -1,5 +1,7 @@
import { computed, onScopeDispose, reactive, type Ref, watch } from 'vue'
import { PROJECT_CONTENT_LINK_BLOCKLIST } from '../project-links/index.ts'
interface MessageDescriptor {
id: string
defaultMessage?: string
@@ -104,7 +106,7 @@ function anchored(source: string): RegExp {
function blacklist(label: string, ...domains: string[]): LinkCheckBuilder {
const pattern = domains.map((domain) => domain.replace(/\./g, '\\.')).join('|')
return check(new RegExp(`^(?:${pattern})`, 'i'), label)
return check(new RegExp(`^(?:[^./:?#]+\\.)*(?:${pattern})(?=[:/?#]|$)`, 'i'), label)
}
function buildNode(when: LinkCheckMatcher, label?: string): LinkCheckBuilder {
@@ -829,32 +831,7 @@ checks.children(
)
checks.children(
blacklist('URL Shortener', 'bit.ly', 'adf.ly', 'tinyurl.com', 'short.io', 'is.gd'),
// Social Media
blacklist('Twitter', 'twitter.com', 'x.com'),
blacklist('Instagram', 'instagram.com'),
blacklist('Facebook', 'facebook.com'),
blacklist('TikTok', 'tiktok.com'),
blacklist('Telegram', 'telegram.org', 't.me'),
blacklist('Bilibili', 'bilibili.com'),
blacklist('Bluesky', 'bsky.app'),
blacklist('Twitch', 'twitch.tv'),
blacklist('Reddit', 'reddit.com', 'redd.it'),
// Minecraft
blacklist('Modrinth', 'modrinth.com'),
blacklist('Minecraft', 'minecraft.net'),
//TODO we should probably setup curseforge/planetminecraft issues for issues but im too lazy to do that rn
blacklist(
'Mod Distribution Platform',
'curseforge.com',
'planetminecraft.com',
'9minecraft.net',
'mcmod.cn',
),
blacklist('AI Mod Generation Platform', 'creativemode.net', 'orcaclient.com', 'autoforged.cn'),
...PROJECT_CONTENT_LINK_BLOCKLIST.map(({ label, domains }) => blacklist(label, ...domains)),
)
export { checkLink, getLinkCheckState, isLinkCheckPending, useLinkCheck }
@@ -67,6 +67,25 @@ test('allows unrecognized valid links but keeps global restrictions in general c
assert.equal(getLinkCheckState(blocked)?.severity, 'error')
})
test('blocks subdomains of blocklisted hosts without blocking lookalike domains', async () => {
const blocked = {
field: 'description',
url: 'https://social.modrinth.com/project',
generalContent: true,
}
const allowed = {
field: 'description',
url: 'https://modrinth.com.example.dev/project',
generalContent: true,
}
await checkLink(blocked)
await checkLink(allowed)
assert.equal(getLinkCheckState(blocked)?.severity, 'error')
assert.equal(getLinkCheckState(allowed)?.severity, 'valid')
})
test('compares recognized license URLs with the selected license', async () => {
const matching = {
field: 'license',
@@ -2,6 +2,7 @@ import LinkifyIt from 'linkify-it'
import { getNonStandardTextRatio, validateNonStandardText } from '../non-standard-text/index.ts'
import { validateProfanity } from '../profanity/index.ts'
import { getBlockedProjectContentLink } from '../project-links/index.ts'
export interface ProjectFieldMessageDescriptor {
id: string
@@ -26,6 +27,7 @@ export type ProjectTextValidationCode =
| 'text-slur'
| 'text-profanity'
| 'text-non-standard'
| 'text-banned-link'
| 'title-version-number'
| 'title-minecraft-branding'
| 'summary-link'
@@ -67,6 +69,10 @@ const messages = defineMessages({
id: 'project.text-validation.non-standard-text',
defaultMessage: 'Non-standard text characters are not allowed.',
},
bannedLink: {
id: 'project.text-validation.banned-link',
defaultMessage: '“{url}” is not allowed in project summaries or descriptions.',
},
titleVersionNumber: {
id: 'project.text-validation.title-version-number',
defaultMessage: 'Names are not allowed to include version numbers.',
@@ -76,10 +82,6 @@ const messages = defineMessages({
defaultMessage:
'Projects must not use Minecraft\'s branding or include "Minecraft" as a significant part of the name.',
},
summaryLink: {
id: 'project.text-validation.summary-link',
defaultMessage: 'Links should not be included in project summaries.',
},
summaryMatchesTitle: {
id: 'project.text-validation.summary-matches-title',
defaultMessage: 'A project summary should not be the same as its title.',
@@ -92,7 +94,7 @@ const messages = defineMessages({
summarySpecialFormatting: {
id: 'nags.summary-special-formatting.description',
defaultMessage:
'Your summary should not contain formatting, line breaks, or special characters, since the summary will only display plain text.',
'Your summary should not contain formatting, line breaks, special characters, or links. The summary only displays plain text.',
},
descriptionRequired: {
id: 'nags.add-description.description',
@@ -151,6 +153,22 @@ export function containsProjectLinkOrIp(text: string) {
return linkify.test(text)
}
export function containsExplicitHttpProjectLink(text: string) {
return (linkify.match(text) ?? []).some((match) => {
const schema = match.schema.toLowerCase()
return schema === 'http:' || schema === 'https:'
})
}
export function findBlockedProjectContentLink(text: string) {
for (const url of extractProjectLinks(text)) {
const blockedLink = getBlockedProjectContentLink(url)
if (blockedLink) return blockedLink
}
return null
}
export function hasProjectSummaryFormatting(summary: string) {
return Boolean(
summary.match(/# .*/g) ||
@@ -195,7 +213,7 @@ export function countText(markdown: string): number {
.replace(/\[[^\]]*]\([^)]+\)/g, ' ')
const withoutHtml = withoutImagesAndLinks.replace(/<[^>]+>/g, ' ')
const withoutMarkdownSyntax = withoutHtml
.replace(/^>{1}\s?.*$/gm, ' ')
.replace(/^(?:>[ \t]?)+/gm, '')
.replace(/^#{1,6}\s+/gm, ' ')
.replace(/[*_~`>-]/g, ' ')
.replace(/\|/g, ' ')
@@ -317,11 +335,21 @@ export function validateProjectSummary(
const results = validateProjectText(summary)
if (results.length > 0 || !summary) return results
if (containsProjectLinkOrIp(summary)) {
return [{ code: 'summary-link', severity: 'warn', message: messages.summaryLink }]
const blockedLink = findBlockedProjectContentLink(summary)
if (blockedLink) {
return [
{
code: 'text-banned-link',
severity: 'error',
message: messages.bannedLink,
values: blockedLink,
},
]
}
if (title && projectSummaryMatchesTitle(summary, title)) {
const containsExplicitLink = containsExplicitHttpProjectLink(summary)
if (!containsExplicitLink && title && projectSummaryMatchesTitle(summary, title)) {
return [
{
code: 'summary-matches-title',
@@ -332,7 +360,7 @@ export function validateProjectSummary(
}
const length = normalizeProjectFieldText(summary).length
if (length < MIN_SUMMARY_CHARS) {
if (!containsExplicitLink && length < MIN_SUMMARY_CHARS) {
results.push({
code: 'summary-too-short',
severity: 'warn',
@@ -341,10 +369,10 @@ export function validateProjectSummary(
})
}
if (hasProjectSummaryFormatting(summary)) {
if (hasProjectSummaryFormatting(summary) || containsExplicitLink) {
results.push({
code: 'summary-special-formatting',
severity: 'warn',
code: containsExplicitLink ? 'summary-link' : 'summary-special-formatting',
severity: containsExplicitLink ? 'error' : 'warn',
message: messages.summarySpecialFormatting,
})
}
@@ -386,6 +414,18 @@ export function validateProjectDescription(
]
}
const blockedLink = findBlockedProjectContentLink(normalizedDescription)
if (blockedLink) {
return [
{
code: 'text-banned-link',
severity: 'error',
message: messages.bannedLink,
values: blockedLink,
},
]
}
const readableLength = countText(normalizedDescription)
if (readableLength < MIN_DESCRIPTION_CHARS) {
results.push({
@@ -2,8 +2,12 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import {
containsExplicitHttpProjectLink,
containsProjectLinkOrIp,
countText,
extractProjectLinks,
MIN_CHARS_PER_IMAGE,
MIN_DESCRIPTION_CHARS,
projectSummaryMatchesTitle,
validateProjectDescription,
validateProjectSummary,
@@ -25,6 +29,13 @@ test('detects links and IP addresses but not email addresses or game versions',
assert.equal(containsProjectLinkOrIp('Contact hello@example.com'), false)
})
test('distinguishes explicit HTTP links from bare domains', () => {
assert.equal(containsExplicitHttpProjectLink('Visit https://myserver.com'), true)
assert.equal(containsExplicitHttpProjectLink('Visit HTTP://myserver.com'), true)
assert.equal(containsExplicitHttpProjectLink('Visit myserver.com'), false)
assert.equal(containsExplicitHttpProjectLink('The protocol is https://'), false)
})
test('extracts and deduplicates normalized links', () => {
assert.deepEqual(
extractProjectLinks(
@@ -87,11 +98,31 @@ test('validates project titles', () => {
})
test('validates project summaries', () => {
const summaryContentMessage =
'Your summary should not contain formatting, line breaks, special characters, or links. The summary only displays plain text.'
assert.equal(
validateProjectSummary('Visit modrinth.com', 'Project title')[0]?.message.id,
'project.text-validation.summary-link',
validateProjectSummary('Visit https://example.dev', 'Project title')[0]?.message.id,
'nags.summary-special-formatting.description',
)
assert.equal(
validateProjectSummary('Visit https://example.dev', 'Project title')[0]?.message.defaultMessage,
summaryContentMessage,
)
assert.equal(
validateProjectSummary('Visit https://example.dev', 'Project title')[0]?.severity,
'error',
)
assert.equal(
validateProjectSummary('Visit http://example.dev', 'Project title')[0]?.code,
'summary-link',
)
assert.deepEqual(
validateProjectSummary(
'Connect at myserver.com to join our friendly community',
'Project title',
),
[],
)
assert.equal(validateProjectSummary('Visit modrinth.com', 'Project title')[0]?.severity, 'warn')
assert.equal(
validateProjectSummary(' Caf\u00e9 ', 'Cafe\u0301')[0]?.message.id,
'project.text-validation.summary-matches-title',
@@ -117,6 +148,47 @@ test('validates project summaries', () => {
validateProjectSummary('# Short summary', 'Project title').map(({ code }) => code),
['summary-too-short', 'summary-special-formatting'],
)
assert.equal(
validateProjectSummary('# Short summary', 'Project title')[1]?.message.defaultMessage,
summaryContentMessage,
)
})
test('rejects blocklisted links and IP addresses in summaries and descriptions', () => {
const blockedSummary = validateProjectSummary(
'Visit https://social.modrinth.com/project',
'Title',
)
assert.deepEqual(blockedSummary[0], {
code: 'text-banned-link',
severity: 'error',
message: {
id: 'project.text-validation.banned-link',
defaultMessage: '“{url}” is not allowed in project summaries or descriptions.',
},
values: {
label: 'Modrinth',
url: 'https://social.modrinth.com/project',
},
})
const blockedDescription = validateProjectDescription(
`A detailed project description with https://bit.ly/project. ${'More details. '.repeat(20)}`,
)
assert.equal(blockedDescription[0]?.code, 'text-banned-link')
assert.equal(blockedDescription[0]?.values?.label, 'URL shortener')
const blockedIp = validateProjectSummary('Join 127.0.0.1:25565 to play', 'Title')
assert.equal(blockedIp[0]?.code, 'text-banned-link')
assert.equal(blockedIp[0]?.values?.label, 'IP address')
const allowedDescription = validateProjectDescription(
`Read more at https://example.dev/project. ${'More details. '.repeat(20)}`,
)
assert.equal(
allowedDescription.some(({ code }) => code === 'text-banned-link'),
false,
)
})
test('allows sparse non-standard text in descriptions but rejects it at the threshold', () => {
@@ -162,6 +234,34 @@ test('allows one profanity match in descriptions but rejects a second match or a
assert.equal(validateProjectDescription(`${description} nigger`)[0]?.code, 'text-slur')
})
test('counts blockquote content as readable description text', () => {
assert.equal(countText('> Quoted text'), 'Quoted text'.length)
assert.equal(
countText('> First line\n> > Nested line\n>\n> - Quoted list item'),
'First line Nested line Quoted list item'.length,
)
const quotedDescription = `> ${'A'.repeat(MIN_DESCRIPTION_CHARS)}`
assert.equal(
validateProjectDescription(quotedDescription).some(
({ code }) => code === 'description-too-short',
),
false,
)
const images = ['![One](one.png)', '![Two](two.png)', '![Three](three.png)', '![Four](four.png)']
const quotedImageDescription = [
`> ${'A'.repeat(MIN_CHARS_PER_IMAGE * images.length)}`,
...images,
].join('\n')
assert.equal(
validateProjectDescription(quotedImageDescription).some(
({ code }) => code === 'description-image-heavy',
),
false,
)
})
test('validates required description content and returns simultaneous recommendations', () => {
assert.equal(validateProjectDescription(' ')[0]?.code, 'description-required')
@@ -0,0 +1,65 @@
export interface ProjectContentLinkBlocklistEntry {
label: string
domains: readonly string[]
}
export const PROJECT_CONTENT_LINK_SHORTENERS = [
'bit.ly',
'adf.ly',
'tinyurl.com',
'short.io',
'is.gd',
] as const
export const PROJECT_CONTENT_LINK_BLOCKLIST: readonly ProjectContentLinkBlocklistEntry[] = [
{
label: 'URL shortener',
domains: PROJECT_CONTENT_LINK_SHORTENERS,
},
{ 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: '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'],
},
]
export interface BlockedProjectContentLink extends Record<string, unknown> {
label: string
url: string
}
function isIpAddress(hostname: string) {
const strippedHostname = hostname.replace(/^\[|]$/g, '')
return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(strippedHostname) || strippedHostname.includes(':')
}
export function getBlockedProjectContentLink(url: string): BlockedProjectContentLink | null {
let hostname: string
try {
hostname = new URL(url).hostname.toLowerCase().replace(/\.$/, '')
} catch {
return null
}
if (isIpAddress(hostname)) return { label: 'IP address', url }
const entry = PROJECT_CONTENT_LINK_BLOCKLIST.find(({ domains }) =>
domains.some((domain) => hostname === domain || hostname.endsWith(`.${domain}`)),
)
return entry ? { label: entry.label, url } : null
}
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { getBlockedProjectContentLink, PROJECT_CONTENT_LINK_BLOCKLIST } from './index.ts'
test('blocks every configured project-content domain and its subdomains', () => {
for (const { label, domains } of PROJECT_CONTENT_LINK_BLOCKLIST) {
for (const domain of domains) {
assert.deepEqual(getBlockedProjectContentLink(`https://${domain}/project`), {
label,
url: `https://${domain}/project`,
})
assert.equal(
getBlockedProjectContentLink(`https://subdomain.${domain}/project`)?.label,
label,
)
}
}
})
test('blocks IP-address URLs without blocking domain lookalikes', () => {
assert.equal(getBlockedProjectContentLink('http://127.0.0.1:25565')?.label, 'IP address')
assert.equal(getBlockedProjectContentLink('https://[2001:db8::1]')?.label, 'IP address')
assert.equal(getBlockedProjectContentLink('https://modrinth.com.example.dev'), null)
assert.equal(getBlockedProjectContentLink('not a URL'), null)
})
@@ -89,10 +89,10 @@ test('reports whether a project has field validation failures', () => {
assert.equal(hasProjectFieldValidationFailures(invalidProject), true)
})
test('treats version numbers as errors and summary content recommendations as warnings', () => {
test('treats version numbers and explicit summary links as errors', () => {
const project = createProject({
name: 'Tools 1.2.3',
summary: 'Visit modrinth.com for more information',
summary: 'Visit https://example.dev for more information',
})
const result = validateProjectFields(project)
@@ -101,7 +101,7 @@ test('treats version numbers as errors and summary content recommendations as wa
result.failures.map(({ code, severity }) => ({ code, severity })),
[
{ code: 'title-version-number', severity: 'error' },
{ code: 'summary-link', severity: 'warn' },
{ code: 'summary-link', severity: 'error' },
],
)
assert.equal(hasProjectFieldValidationFailures(project), true)
@@ -111,6 +111,22 @@ test('treats version numbers as errors and summary content recommendations as wa
)
})
test('rejects blocklisted links in summaries and descriptions', () => {
const summaryResult = validateProjectFields(
createProject({ summary: 'Visit modrinth.com for more information' }),
)
assert.equal(summaryResult.valid, false)
assert.equal(summaryResult.failures[0]?.field, 'summary')
assert.equal(summaryResult.failures[0]?.code, 'text-banned-link')
const descriptionResult = validateProjectFields(
createProject({ description: `Visit https://bit.ly/project. ${'More details. '.repeat(20)}` }),
)
assert.equal(descriptionResult.valid, false)
assert.equal(descriptionResult.failures[0]?.field, 'description')
assert.equal(descriptionResult.failures[0]?.code, 'text-banned-link')
})
test('reports summary recommendations without invalidating the project', () => {
const project = createProject({ summary: 'Short summary' })