feat: get header length by rendering markdown to html

This commit is contained in:
tdgao
2026-08-28 14:13:52 -06:00
parent a926cff5f4
commit 84a16d7964
4 changed files with 111 additions and 11 deletions
+1
View File
@@ -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"
@@ -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) {
@@ -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 =
'<b><font color="#FF5555">W</font><font color="#FFAA00">O</font><font color="#55FF55">W</font><font color="#55FFFF">!</font></b>'
assert.deepEqual(extractRenderedHeaders(`### ${styledHeader}`), ['WOW!'])
assert.deepEqual(analyzeHeaderLength(`### ${styledHeader}`), {
hasLongHeaders: false,
longHeaders: [],
})
const longStyledHeader = `<b>${'A'.repeat(81)}</b>`
assert.deepEqual(analyzeHeaderLength(`### ${longStyledHeader}`), {
hasLongHeaders: true,
longHeaders: ['A'.repeat(81)],
})
assert.deepEqual(analyzeHeaderLength('### <b>First sentence. Second sentence.</b>'), {
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(`<h3>${'A'.repeat(81)}</h3>`), {
hasLongHeaders: true,
longHeaders: ['A'.repeat(81)],
})
assert.deepEqual(analyzeHeaderLength(`<h4>${'A'.repeat(81)}</h4>`), {
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.'
+17
View File
@@ -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: {}