fix: summary html tag thats opening tag only, allow name to have version number when port or fork

This commit is contained in:
tdgao
2026-09-01 15:15:23 -06:00
parent 621097bcf3
commit 5dd95fedde
6 changed files with 74 additions and 26 deletions
@@ -77,6 +77,12 @@
"nags.description-too-short.title": {
"defaultMessage": "Expand the description"
},
"nags.disclosures-special-formatting.description": {
"defaultMessage": "Content disclosures cannot contain HTML, as they display in plain text."
},
"nags.disclosures-special-formatting.title": {
"defaultMessage": "Remove HTML from content disclosures"
},
"nags.edit-description.title": {
"defaultMessage": "Edit description"
},
@@ -282,7 +288,7 @@
"defaultMessage": "Review the project summary"
},
"nags.project-summary-links.description": {
"defaultMessage": "Links, URLs, or IPs are not allowed in the project summary."
"defaultMessage": "Links, URLs, or IPs are not allowed in the project summary. Detected: {value}"
},
"nags.project-summary-links.title": {
"defaultMessage": "Remove links from the summary"
@@ -105,11 +105,9 @@ export const projectNameValidationRules = {
severity: 'error',
evaluate: (projectName) => {
const normalizedName = projectName.normalize('NFC').toLowerCase()
const includesVersionNumber = [...normalizedName.matchAll(/\d+(?:\.\d+)+/g)].some((match) => {
const textAfterVersion = normalizedName.slice((match.index ?? 0) + match[0].length)
return !/\b(?:port|fork)\b/.test(textAfterVersion)
})
return { valid: !includesVersionNumber }
const isPortOrFork = normalizedName.includes('port') || normalizedName.includes('fork')
const includesVersionNumber = /\d+(?:\.\d+)+/.test(normalizedName)
return { valid: !includesVersionNumber || isPortOrFork }
},
presentation: {
message: messages.versionNumber,
@@ -84,7 +84,7 @@ const messages = defineMessages({
},
links: {
id: 'nags.project-summary-links.description',
defaultMessage: 'Links, URLs, or IPs are not allowed in the project summary.',
defaultMessage: 'Links, URLs, or IPs are not allowed in the project summary. Detected: {value}',
},
})
@@ -101,8 +101,8 @@ const summaryLinkify = new LinkifyIt({
fuzzyLink: true,
}).tlds(tlds)
function containsProjectSummaryLinkOrIp(summary: string): boolean {
return summaryLinkify.test(summary)
function findProjectSummaryLinkOrIp(summary: string): string | null {
return summaryLinkify.match(summary)?.[0].raw ?? null
}
export function projectSummaryMatchesName(summary: string, name: string) {
@@ -153,7 +153,7 @@ export const projectSummaryValidationRules = {
if (
!normalized ||
normalized.length < MIN_SUMMARY_CHARS ||
containsProjectSummaryLinkOrIp(normalized) ||
findProjectSummaryLinkOrIp(normalized) !== null ||
!validateSpam(normalized).valid
) {
return { valid: true }
@@ -170,7 +170,7 @@ export const projectSummaryValidationRules = {
evaluate: ({ summary, name }) => ({
valid:
!summary ||
containsProjectSummaryLinkOrIp(summary) ||
findProjectSummaryLinkOrIp(summary) !== null ||
!name ||
!projectSummaryMatchesName(summary, name),
}),
@@ -182,7 +182,7 @@ export const projectSummaryValidationRules = {
'summary-too-short': {
severity: 'error',
evaluate: ({ summary }) => {
if (!summary || containsProjectSummaryLinkOrIp(summary)) return { valid: true }
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 } }
@@ -215,9 +215,10 @@ export const projectSummaryValidationRules = {
},
'project-summary-links': {
severity: 'error',
evaluate: ({ summary }) => ({
valid: !summary || !containsProjectSummaryLinkOrIp(summary),
}),
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 },
@@ -1,6 +1,6 @@
import type { Labrinth } from '@modrinth/api-client'
import { formatCategory } from '@modrinth/ui/src/utils/tag-messages.ts'
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'
@@ -81,7 +81,31 @@ test('evaluates matching rules in definition order and converts them to field me
test('allows clean project names and versioned ports', () => {
assert.deepEqual(evaluateRules('Sodium Extras', projectNameValidationRules), [])
assert.deepEqual(evaluateRules('Sodium 1.20 Port', 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', () => {
@@ -186,7 +210,6 @@ 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',
@@ -196,6 +219,17 @@ test('detects Markdown and HTML formatting in project summaries', () => {
}
})
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.',
@@ -273,14 +307,17 @@ test('rejects HTML in disclosure text', () => {
})
test('rejects every link and IP address in project summaries', () => {
for (const summary of [
'Visit https://example.dev for more information about this project',
'Visit example.dev for more information about this project',
'Join 127.0.0.1:25565 for more information about this project',
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 }) => code),
['project-summary-links'],
validateProjectSummary({ summary, name: 'Project title' }).map(({ code, values }) => ({
code,
values,
})),
[{ code: 'project-summary-links', values: { value } }],
)
}
@@ -18,13 +18,19 @@ const allowedPlainTextBlockTokenTypes = new Set(['paragraph_open', 'inline', 'pa
const allowedPlainTextInlineTokenTypes = new Set(['text', 'softbreak', 'hardbreak'])
export function hasProjectTextFormatting(text: string) {
return projectPlainTextMarkdown.parse(text, {}).some((token) => {
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) => !allowedPlainTextInlineTokenTypes.has(child.type)) ?? false
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