diff --git a/packages/moderation/package.json b/packages/moderation/package.json index fea0e3f07b..aa1993f2ad 100644 --- a/packages/moderation/package.json +++ b/packages/moderation/package.json @@ -16,6 +16,7 @@ "@modrinth/utils": "workspace:*", "@modrinth/api-client": "workspace:*", "linkify-it": "^5.0.0", + "node-html-parser": "^9.0.1", "obscenity": "^0.4.6", "tlds": "^1.261.0", "vue": "^3.5.13" diff --git a/packages/moderation/src/validation-rules/rules/description.ts b/packages/moderation/src/validation-rules/rules/description.ts index d0f4ee5c79..1e1254915b 100644 --- a/packages/moderation/src/validation-rules/rules/description.ts +++ b/packages/moderation/src/validation-rules/rules/description.ts @@ -1,5 +1,7 @@ import { defineMessages } from '@modrinth/ui/i18n' +import { renderString } from '@modrinth/utils/parse.ts' import LinkifyIt from 'linkify-it' +import { parse } from 'node-html-parser' import tlds from 'tlds' with { type: 'json' } import type { Nag, ProjectValidationContext } from '../../types/nags.ts' @@ -105,6 +107,8 @@ const descriptionLinkify = new LinkifyIt({ fuzzyLink: true, }).tlds(tlds) +const headerCharacterSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }) + export function extractDescriptionLinks(description: string): string[] { const matches = descriptionLinkify.match(description) ?? [] return [ @@ -133,20 +137,26 @@ export function findBannedDescriptionLink(description: string): string | null { return null } +export function extractRenderedHeaders(markdown: string): string[] { + if (!markdown) return [] + + const renderedDescription = parse(renderString(markdown)) + return renderedDescription + .querySelectorAll('h1, h2, h3') + .map((header) => header.textContent.replace(/\s+/g, ' ').trim()) +} + +function countHeaderCharacters(header: string): number { + return [...headerCharacterSegmenter.segment(header)].length +} + export function analyzeHeaderLength(markdown: string): { hasLongHeaders: boolean longHeaders: string[] } { - if (!markdown) return { hasLongHeaders: false, longHeaders: [] } - - const withoutCodeBlocks = markdown.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '') - const headers = [...withoutCodeBlocks.matchAll(/^(#{1,3})\s+(.+)$/gm)] - const longHeaders = headers - .map((match) => match[2].trim()) - .filter((headerText) => { - const sentences = headerText.split(/[.!?]+/g).filter((sentence) => sentence.trim().length > 0) - return headerText.length > MAX_HEADER_LENGTH || sentences.length > 1 - }) + const longHeaders = extractRenderedHeaders(markdown).filter( + (header) => countHeaderCharacters(header) > MAX_HEADER_LENGTH, + ) return { hasLongHeaders: longHeaders.length > 0, longHeaders } } @@ -280,7 +290,7 @@ export const projectDescriptionValidationRules = { }, }, 'long-headers': { - severity: 'warning', + severity: 'error', evaluate: (description) => { const { longHeaders } = analyzeHeaderLength(description ?? '') if (longHeaders.length > 0) { diff --git a/packages/moderation/src/validation-rules/tests.ts b/packages/moderation/src/validation-rules/tests.ts index ae209e1161..6f8b4cb068 100644 --- a/packages/moderation/src/validation-rules/tests.ts +++ b/packages/moderation/src/validation-rules/tests.ts @@ -3,10 +3,12 @@ import test from 'node:test' import { evaluateRules } from './evaluate-rules.ts' import { + analyzeHeaderLength, analyzeImageContent, BANNED_DESCRIPTION_LINK_DOMAINS, countText, extractDescriptionLinks, + extractRenderedHeaders, findBannedDescriptionLink, MIN_DESCRIPTION_CHARS, validateProjectDescription, @@ -192,6 +194,76 @@ test('validates description requirements and simultaneous recommendations', () = ) }) +test('allows short headers regardless of punctuation', () => { + assert.deepEqual(analyzeHeaderLength('# Version 1.2 is available'), { + hasLongHeaders: false, + longHeaders: [], + }) + assert.deepEqual(analyzeHeaderLength('# Install version 1.2. Enjoy!'), { + hasLongHeaders: false, + longHeaders: [], + }) +}) + +test('validates Setext headers', () => { + assert.deepEqual( + analyzeHeaderLength('Version 1.2 is available\n===\n\nFirst sentence. Second sentence.\n---'), + { + hasLongHeaders: false, + longHeaders: [], + }, + ) + assert.deepEqual(analyzeHeaderLength(`${'A'.repeat(81)}\n===`), { + hasLongHeaders: true, + longHeaders: ['A'.repeat(81)], + }) +}) + +test('validates visible header text without counting markup', () => { + const styledHeader = + 'WOW!' + assert.deepEqual(extractRenderedHeaders(`### ${styledHeader}`), ['WOW!']) + assert.deepEqual(analyzeHeaderLength(`### ${styledHeader}`), { + hasLongHeaders: false, + longHeaders: [], + }) + + const longStyledHeader = `${'A'.repeat(81)}` + assert.deepEqual(analyzeHeaderLength(`### ${longStyledHeader}`), { + hasLongHeaders: true, + longHeaders: ['A'.repeat(81)], + }) + assert.deepEqual(analyzeHeaderLength('### First sentence. Second sentence.'), { + hasLongHeaders: false, + longHeaders: [], + }) + assert.deepEqual(analyzeHeaderLength(`### [Docs](https://example.com/${'a'.repeat(81)})`), { + hasLongHeaders: false, + longHeaders: [], + }) +}) + +test('validates rendered heading levels one through three using grapheme counts', () => { + assert.deepEqual(analyzeHeaderLength(`