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(`

${'A'.repeat(81)}

`), { + hasLongHeaders: true, + longHeaders: ['A'.repeat(81)], + }) + assert.deepEqual(analyzeHeaderLength(`

${'A'.repeat(81)}

`), { + hasLongHeaders: false, + longHeaders: [], + }) + + const emoji = '๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ' + assert.deepEqual(analyzeHeaderLength(`### ${emoji.repeat(80)}`), { + hasLongHeaders: false, + longHeaders: [], + }) + assert.deepEqual(analyzeHeaderLength(`### ${emoji.repeat(81)}`), { + hasLongHeaders: true, + longHeaders: [emoji.repeat(81)], + }) +}) + test('requires 125 readable description characters', () => { const description = 'This project adds useful tools, flexible behavior, accessible documentation, polished gameplay, and support for every player.' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7cc103e2a4..7733796a46 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -560,6 +560,9 @@ importers: linkify-it: specifier: ^5.0.0 version: 5.0.0 + node-html-parser: + specifier: ^9.0.1 + version: 9.0.1 obscenity: specifier: ^0.4.6 version: 0.4.6 @@ -6174,6 +6177,10 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -7943,6 +7950,9 @@ packages: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true + node-html-parser@9.0.1: + resolution: {integrity: sha512-QrdiYYm1NnLRXsMXThUgVcF/syWfWIgHFmy8hylWMGFbHFtnRuXLBxxGQvIm3xaIJMUDJ0ayOmT/FGJYT3pZIw==} + node-mock-http@1.0.4: resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==} @@ -16006,6 +16016,8 @@ snapshots: entities@7.0.1: {} + entities@8.0.0: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -18511,6 +18523,11 @@ snapshots: node-gyp-build@4.8.4: {} + node-html-parser@9.0.1: + dependencies: + css-select: 5.2.2 + entities: 8.0.0 + node-mock-http@1.0.4: {} node-releases@2.0.27: {}