feat: check summary formatting by move link detection into its own validator

This commit is contained in:
tdgao
2026-08-28 14:31:05 -06:00
parent 84a16d7964
commit df58a2e2f9
4 changed files with 72 additions and 35 deletions
@@ -266,6 +266,12 @@
"nags.project-summary-content.title": {
"defaultMessage": "Review the project summary"
},
"nags.project-summary-links.description": {
"defaultMessage": "Any links, URLs, or IPs are not allowed in the project summary."
},
"nags.project-summary-links.title": {
"defaultMessage": "Remove links from the summary"
},
"nags.project-summary-non-standard-text.description": {
"defaultMessage": "Non-standard text characters, such as “₮ɆӾ₮”, are not allowed."
},
@@ -345,7 +351,7 @@
"defaultMessage": "Visit versions settings"
},
"nags.summary-special-formatting.description": {
"defaultMessage": "Your summary should not contain formatting, line breaks, special characters, or links. The summary only displays plain text."
"defaultMessage": "Your summary cannot not contain Markdown or HTML, as it displays in plain text."
},
"nags.summary-special-formatting.title": {
"defaultMessage": "Clean up the summary"
@@ -1,4 +1,5 @@
import { defineMessages } from '@modrinth/ui/i18n'
import { md } from '@modrinth/utils/parse.ts'
import LinkifyIt from 'linkify-it'
import tlds from 'tlds' with { type: 'json' }
@@ -34,7 +35,11 @@ const messages = defineMessages({
},
cleanUpSummary: {
id: 'nags.summary-special-formatting.title',
defaultMessage: 'Clean up the summary',
defaultMessage: 'Remove Markdown and HTML from the summary',
},
removeSummaryLinks: {
id: 'nags.project-summary-links.title',
defaultMessage: 'Remove links from the summary',
},
editSummary: {
id: 'nags.edit-summary.title',
@@ -68,8 +73,11 @@ const messages = defineMessages({
},
specialFormatting: {
id: 'nags.summary-special-formatting.description',
defaultMessage:
'Your summary should not contain formatting, line breaks, special characters, or links. The summary only displays plain text.',
defaultMessage: 'Your summary cannot contain Markdown or HTML, as it displays in plain text.',
},
links: {
id: 'nags.project-summary-links.description',
defaultMessage: 'Links, URLs, or IPs are not allowed in the project summary.',
},
})
@@ -86,6 +94,10 @@ const summaryLinkify = new LinkifyIt({
fuzzyLink: true,
}).tlds(tlds)
const summaryMarkdown = md({ linkify: false })
const allowedSummaryBlockTokenTypes = new Set(['paragraph_open', 'inline', 'paragraph_close'])
const allowedSummaryInlineTokenTypes = new Set(['text', 'softbreak', 'hardbreak'])
function containsProjectSummaryLinkOrIp(summary: string): boolean {
return summaryLinkify.test(summary)
}
@@ -98,17 +110,11 @@ export function projectSummaryMatchesName(summary: string, name: string) {
}
export function hasProjectSummaryFormatting(summary: string) {
return Boolean(
summary.match(/# .*/g) ||
summary.match(/---/g) ||
summary.match(/\n/g) ||
summary.match(/`.*`/g) ||
summary.match(/\*.*\*/g) ||
summary.match(/_.*_/g) ||
summary.match(/~~.*~~/g) ||
summary.match(/```/g) ||
summary.match(/> /g),
)
return summaryMarkdown.parse(summary, {}).some((token) => {
if (!allowedSummaryBlockTokenTypes.has(token.type)) return true
return token.children?.some((child) => !allowedSummaryInlineTokenTypes.has(child.type)) ?? false
})
}
const commonNagPresentation = {
@@ -182,15 +188,23 @@ export const projectSummaryValidationRules = {
'summary-special-formatting': {
severity: 'error',
evaluate: ({ summary }) => ({
valid:
!summary ||
(!hasProjectSummaryFormatting(summary) && !containsProjectSummaryLinkOrIp(summary)),
valid: !summary || !hasProjectSummaryFormatting(summary),
}),
presentation: {
message: messages.specialFormatting,
nag: { title: messages.cleanUpSummary, ...commonNagPresentation },
},
},
'project-summary-links': {
severity: 'error',
evaluate: ({ summary }) => ({
valid: !summary || !containsProjectSummaryLinkOrIp(summary),
}),
presentation: {
message: messages.links,
nag: { title: messages.removeSummaryLinks, ...commonNagPresentation },
},
},
} satisfies ValidationRuleSet<ProjectSummaryValidationInput>
export function validateProjectSummary(
@@ -15,7 +15,11 @@ import {
} from './rules/description.ts'
import { validateProjectGalleryDescription, validateProjectGalleryName } from './rules/gallery.ts'
import { projectNameValidationRules, validateProjectNameField } from './rules/name.ts'
import { projectSummaryMatchesName, validateProjectSummary } from './rules/summary.ts'
import {
hasProjectSummaryFormatting,
projectSummaryMatchesName,
validateProjectSummary,
} from './rules/summary.ts'
import { toFieldMessages } from './to-field-messages.ts'
import { toNags } from './to-nags.ts'
import type { ValidationRuleSet } from './types.ts'
@@ -141,6 +145,32 @@ test('rejects repeated summary padding', () => {
)
})
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>',
'Visible content <!-- hidden HTML content -->',
'**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('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 every link and IP address in project summaries', () => {
for (const summary of [
'Visit https://example.dev for more information about this project',
@@ -149,7 +179,7 @@ test('rejects every link and IP address in project summaries', () => {
]) {
assert.deepEqual(
validateProjectSummary({ summary, name: 'Project title' }).map(({ code }) => code),
['summary-special-formatting'],
['project-summary-links'],
)
}
@@ -51,21 +51,8 @@ const FANCY_RANGES: ReadonlyArray<readonly [number, number]> = [
]
const ALLOWED_FANCY_CODE_POINTS = new Set([
0x02d6,
0x02d7,
0x02d8,
0x02d9,
0x02da,
0x02db,
0x02dc,
0x02dd,
0x207a,
0x207b,
0x208a,
0x208b,
0x2120,
0x2122,
0x2139,
0x02d6, 0x02d7, 0x02d8, 0x02d9, 0x02da, 0x02db, 0x02dc, 0x02dd, 0x207a, 0x207b, 0x208a, 0x208b,
0x2120, 0x2122, 0x2139,
])
const MARK_PATTERN = /\p{M}/u