mirror of
https://github.com/modrinth/code.git
synced 2026-08-30 19:46:33 +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.'
|
||||
|
||||
Generated
+17
@@ -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: {}
|
||||
|
||||
Reference in New Issue
Block a user