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.'