From df58a2e2f9338245904579d143e4910d76cc0162 Mon Sep 17 00:00:00 2001 From: tdgao Date: Fri, 28 Aug 2026 14:31:05 -0600 Subject: [PATCH] feat: check summary formatting by move link detection into its own validator --- .../moderation/src/locales/en-US/index.json | 8 +++- .../src/validation-rules/rules/summary.ts | 48 ++++++++++++------- .../moderation/src/validation-rules/tests.ts | 34 ++++++++++++- .../src/validators/non-standard-text/index.ts | 17 +------ 4 files changed, 72 insertions(+), 35 deletions(-) diff --git a/packages/moderation/src/locales/en-US/index.json b/packages/moderation/src/locales/en-US/index.json index 50812e7341..54a6ae8749 100644 --- a/packages/moderation/src/locales/en-US/index.json +++ b/packages/moderation/src/locales/en-US/index.json @@ -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" diff --git a/packages/moderation/src/validation-rules/rules/summary.ts b/packages/moderation/src/validation-rules/rules/summary.ts index d7d30b095b..e02e0a6957 100644 --- a/packages/moderation/src/validation-rules/rules/summary.ts +++ b/packages/moderation/src/validation-rules/rules/summary.ts @@ -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 export function validateProjectSummary( diff --git a/packages/moderation/src/validation-rules/tests.ts b/packages/moderation/src/validation-rules/tests.ts index 6f8b4cb068..6e8b88c09b 100644 --- a/packages/moderation/src/validation-rules/tests.ts +++ b/packages/moderation/src/validation-rules/tests.ts @@ -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 🩸Unknown is a dark and unsettling horror-survival mod.', + 'Custom HTML content', + 'Visible 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'], ) } diff --git a/packages/moderation/src/validators/non-standard-text/index.ts b/packages/moderation/src/validators/non-standard-text/index.ts index fc771de7fd..716962b54a 100644 --- a/packages/moderation/src/validators/non-standard-text/index.ts +++ b/packages/moderation/src/validators/non-standard-text/index.ts @@ -51,21 +51,8 @@ const FANCY_RANGES: ReadonlyArray = [ ] 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