mirror of
https://github.com/modrinth/code.git
synced 2026-09-02 13:05:50 +00:00
feat: get header length by rendering markdown to html
This commit is contained in:
@@ -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.'
|
||||
|
||||
Reference in New Issue
Block a user