feat: bump description too short to error, add spam validation for desc and summary, update summary and description too short copy

This commit is contained in:
tdgao
2026-08-28 13:22:10 -06:00
parent 725b851dcc
commit a926cff5f4
38 changed files with 280 additions and 219 deletions
@@ -53,12 +53,6 @@
"nags.check-disclosures.title": {
"defaultMessage": "تحقق من إفصاحات المحتوى"
},
"nags.description-too-short.description": {
"defaultMessage": "وصفك هو {length, plural, one {# readable character} other {# readable characters}}. يُوصى بأن يكون على الأقل {minChars, plural, one {# character}other{#characters}} لإنشاء وصف واضح وغني بالمعلومات."
},
"nags.description-too-short.title": {
"defaultMessage": "قم بتوسيع الوصف"
},
"nags.edit-description.title": {
"defaultMessage": "تعديل الوصف"
},
@@ -53,12 +53,6 @@
"nags.check-disclosures.title": {
"defaultMessage": "Zkontrolovat zveřejnění obsahu"
},
"nags.description-too-short.description": {
"defaultMessage": "Váš popis má {length, plural, one {# čitelný znak} few {# čitelné znaky} other {# čitelných znaků}}. Pro vytvoření jasného a informativního popisu doporučujeme alespoň {minChars, plural, one {# znak} few {# znaky} other {# znaků}}."
},
"nags.description-too-short.title": {
"defaultMessage": "Rozšiřte popis"
},
"nags.edit-description.title": {
"defaultMessage": "Upravte popis"
},
@@ -35,12 +35,6 @@
"nags.all-tags-selected.title": {
"defaultMessage": "Vælg præcise tags"
},
"nags.description-too-short.description": {
"defaultMessage": "Din beskrivelse er {length, plural, one {# læsligt bogstav} other {# læslige bogstaver}}. Som minimum er {minChars, plural, one {# bogstav} other {# bogstaver}} anbefalet for at lave en klar og informativ beskrivelse."
},
"nags.description-too-short.title": {
"defaultMessage": "Forlæng beskrivelsen"
},
"nags.edit-description.title": {
"defaultMessage": "Rediger beskrivelsen"
},
@@ -53,12 +53,6 @@
"nags.check-disclosures.title": {
"defaultMessage": "Angaben zum Inhalt prüfen"
},
"nags.description-too-short.description": {
"defaultMessage": "Deine Beschreibung ist {length, plural, one {# lesbares Zeichen} other {# lesbare Zeichen}} lang. Mindestens {minChars, plural, one {1 Zeichen ist} other {# Zeichen sind}} empfohlen, um eine klare und informative Beschreibung zu erstellen."
},
"nags.description-too-short.title": {
"defaultMessage": "Beschreibung erweitern"
},
"nags.edit-description.title": {
"defaultMessage": "Beschreibung bearbeiten"
},
@@ -53,12 +53,6 @@
"nags.check-disclosures.title": {
"defaultMessage": "Angaben zum Inhalt prüfen"
},
"nags.description-too-short.description": {
"defaultMessage": "Deine Beschreibung enthält {length, plural, one {# lesbares Zeichen} other {# lesbare Zeichen}}. Es wird empfohlen, mindestens {minChars, plural, one {# Zeichen} other {# Zeichen}} zu verwenden, um eine klare und informative Beschreibung zu erstellen."
},
"nags.description-too-short.title": {
"defaultMessage": "Beschreibung erweitern"
},
"nags.edit-description.title": {
"defaultMessage": "Beschreibung bearbeiten"
},
@@ -60,7 +60,7 @@
"defaultMessage": "Check content disclosures"
},
"nags.description-too-short.description": {
"defaultMessage": "Your description is {length, plural, one {# readable character} other {# readable characters}}. At least {minChars, plural, one {# character} other {# characters}} is recommended to create a clear and informative description."
"defaultMessage": "Your description is too brief. Add more to clearly describe the project's purpose and function."
},
"nags.description-too-short.title": {
"defaultMessage": "Expand the description"
@@ -245,6 +245,12 @@
"nags.project-description-slur.description": {
"defaultMessage": "Your project cannot contain any slurs. Detected: “{value}”."
},
"nags.project-description-spam.description": {
"defaultMessage": "Repeated characters, words, or phrases cannot be used to pad a project description."
},
"nags.project-description-spam.title": {
"defaultMessage": "Remove spam from the description"
},
"nags.project-name-non-standard-text.description": {
"defaultMessage": "Non-standard text characters, such as “₮ɆӾ₮”, are not allowed."
},
@@ -269,6 +275,12 @@
"nags.project-summary-slur.description": {
"defaultMessage": "Your project summary cannot contain any slurs. Detected: “{value}”."
},
"nags.project-summary-spam.description": {
"defaultMessage": "Repeated characters, words, or phrases cannot be used to pad a project summary."
},
"nags.project-summary-spam.title": {
"defaultMessage": "Remove spam from the summary"
},
"nags.review-permissions.description": {
"defaultMessage": "Make sure you have provided proof of your permission to distribute any external content in your Modpack."
},
@@ -390,7 +402,7 @@
"defaultMessage": "A project summary cannot be the same as it's title."
},
"project.text-validation.summary-too-short": {
"defaultMessage": "Your summary is {length, plural, one {# character} other {# characters}}. At least {minChars, plural, one {# character} other {# characters}} is recommended to create an informative and enticing summary."
"defaultMessage": "Your summary is too short. Add a sentence or two which describes your project."
},
"project.text-validation.title-version-number": {
"defaultMessage": "Names are not allowed to include version numbers."
@@ -53,12 +53,6 @@
"nags.check-disclosures.title": {
"defaultMessage": "Comprobar el contenido de divulgación"
},
"nags.description-too-short.description": {
"defaultMessage": "Tu descripción tiene {length, plural, one {# carácter legible} other {# caracteres legibles}}. Se recomienda al menos {minChars, plural, one {# carácter} other {# caracteres}} para crear una descripción clara e informativa."
},
"nags.description-too-short.title": {
"defaultMessage": "Expandir la descripción"
},
"nags.edit-description.title": {
"defaultMessage": "Editar descripción"
},
@@ -53,12 +53,6 @@
"nags.check-disclosures.title": {
"defaultMessage": "Comprobar divulgaciones de contenido"
},
"nags.description-too-short.description": {
"defaultMessage": "Tu descripción tiene {length, plural, one {# carácter legible} other {# caracteres legibles}}. Se recomienda un mínimo de {minChars, plural, one {# carácter} other {# caracteres}} para crear una descripción clara e informativa."
},
"nags.description-too-short.title": {
"defaultMessage": "Amplíe la descripción"
},
"nags.edit-description.title": {
"defaultMessage": "Editar descripción"
},
@@ -32,9 +32,6 @@
"nags.all-tags-selected.title": {
"defaultMessage": "Valitse sopivat tagit"
},
"nags.description-too-short.title": {
"defaultMessage": "Laajenna kuvausta"
},
"nags.edit-description.title": {
"defaultMessage": "Muokkaa kuvausta"
},
@@ -35,12 +35,6 @@
"nags.all-tags-selected.title": {
"defaultMessage": "Pumili ng mga pinaktumpak na tag"
},
"nags.description-too-short.description": {
"defaultMessage": "Ang iyong paglalarawan ay may {length, plural, one {# mababasang titik} other {# na mababasang titik}}. Hindi kumulang sa {minChars, plural, one {# titik} other {# na titik}} ang inimumungkahi sa paglikha ng maliwanag at makabuluhang paglalarawan."
},
"nags.description-too-short.title": {
"defaultMessage": "Palawakin ang paglalarawan"
},
"nags.edit-description.title": {
"defaultMessage": "Baguhin ang paglalarawan"
},
@@ -53,12 +53,6 @@
"nags.check-disclosures.title": {
"defaultMessage": "Regarder les avertissements sur le contenu"
},
"nags.description-too-short.description": {
"defaultMessage": "Votre description contient {length, plural, one {# caractère lisible} other {# caractères lisibles}}. Il est recommandé den avoir au moins {minChars, plural, one {# caractère} other {# caractères}} pour obtenir une description claire et informative."
},
"nags.description-too-short.title": {
"defaultMessage": "Étendre la description"
},
"nags.edit-description.title": {
"defaultMessage": "Modifier la description"
},
@@ -47,12 +47,6 @@
"nags.all-tags-selected.title": {
"defaultMessage": "בחר תגיות מדויקות"
},
"nags.description-too-short.description": {
"defaultMessage": "התיאור שלך הוא {length, plural, one {# דמות} other {# דמויות}}. מומלץ להשתמש לפחות ב-{minChars, plural, one {# דמות} other {# דמויות}} כדי ליצור תיאור ברור ואינפורמטיבי."
},
"nags.description-too-short.title": {
"defaultMessage": "הרחב את התיאור"
},
"nags.edit-description.title": {
"defaultMessage": "ערוך תיאור"
},
@@ -53,12 +53,6 @@
"nags.check-disclosures.title": {
"defaultMessage": "Ellenőrizd a tartalomnyilatkozatokat"
},
"nags.description-too-short.description": {
"defaultMessage": "A jelenlegi leírás {length} olvasható karakter hosszú. Egy világos és informatív leíráshoz legalább {minChars} karakter ajánlott."
},
"nags.description-too-short.title": {
"defaultMessage": "Fejtsd ki a leírást"
},
"nags.edit-description.title": {
"defaultMessage": "Leírás szerkesztése"
},
@@ -47,12 +47,6 @@
"nags.all-tags-selected.title": {
"defaultMessage": "Pilih tag yang tepat"
},
"nags.description-too-short.description": {
"defaultMessage": "Panjang keterangan Anda adalah {length, plural, other {# karakter yang dapat dibaca}}. Setidaknya {minChars, plural, other {# karakter}} disarankan untuk membuat keterangan yang jelas dan informatif."
},
"nags.description-too-short.title": {
"defaultMessage": "Kembangkan keterangan"
},
"nags.edit-description.title": {
"defaultMessage": "Sunting keterangan"
},
@@ -50,12 +50,6 @@
"nags.check-disclosures.title": {
"defaultMessage": "Controlla le informative"
},
"nags.description-too-short.description": {
"defaultMessage": "La descrizione è di {length, plural, one {# carattere} other {# caratteri}}. Usane almeno {minChars} per tenerla chiara e informativa."
},
"nags.description-too-short.title": {
"defaultMessage": "Espandi la descrizione"
},
"nags.edit-description.title": {
"defaultMessage": "Modifica la descrizione"
},
@@ -50,12 +50,6 @@
"nags.check-disclosures.title": {
"defaultMessage": "コンテンツの開示を確認"
},
"nags.description-too-short.description": {
"defaultMessage": "説明文が{length, plural, other {#文字}}しかありません。より明確で分かりやすい説明文を作成するためには、少なくとも{minChars, plural, other {#文字}}以上であることが推奨されます。"
},
"nags.description-too-short.title": {
"defaultMessage": "説明を展開する"
},
"nags.edit-description.title": {
"defaultMessage": "説明を編集する"
},
@@ -47,12 +47,6 @@
"nags.all-tags-selected.title": {
"defaultMessage": "올바른 태그를 선택해 주세요"
},
"nags.description-too-short.description": {
"defaultMessage": "작성한 설명이 {length, plural, one {#자} other {#자}}입니다. 명확하고 유익한 설명을 작성하려면 최소 {minChars, plural, one {#자} other {#자}}가 권장됩니다."
},
"nags.description-too-short.title": {
"defaultMessage": "설명을 덧붙여주세요"
},
"nags.edit-description.title": {
"defaultMessage": "설명 수정하기"
},
@@ -35,12 +35,6 @@
"nags.all-tags-selected.title": {
"defaultMessage": "Pilih tag yang tepat"
},
"nags.description-too-short.description": {
"defaultMessage": "Panjang keterangan anda ialah {length, plural, other {# aksara yang boleh dibaca}}. Sekurang-kurangnya {minChars, plural, other {# aksara}} disyorkan untuk membuat keterangan yang jelas dan bermaklumat."
},
"nags.description-too-short.title": {
"defaultMessage": "Kembangkan keterangan"
},
"nags.edit-description.title": {
"defaultMessage": "Sunting keterangan"
},
@@ -47,12 +47,6 @@
"nags.all-tags-selected.title": {
"defaultMessage": "Selecteer relevante tags"
},
"nags.description-too-short.description": {
"defaultMessage": "Je beschrijving is {length, plural, one{# leesbare tekens}other {# leesbare tekens}}. Ten minste {minChars, plural, one {# teken}other {# tekens}} is aangeraden om een duidelijke en informatieve beschrijving te maken."
},
"nags.description-too-short.title": {
"defaultMessage": "Verleng de beschrijving"
},
"nags.edit-description.title": {
"defaultMessage": "Pas de beschrijving aan"
},
@@ -41,12 +41,6 @@
"nags.all-tags-selected.title": {
"defaultMessage": "Velg aktuelle tagger"
},
"nags.description-too-short.description": {
"defaultMessage": "Din beskrivelse inneholder {length, plural, one {# leselig tegn} other {# leselige tegn}}. Minst {minChars, plural, one {# tegn} other {# tegn}} er anbefalt for å lage en klar og informativ beskrivelse."
},
"nags.description-too-short.title": {
"defaultMessage": "Utvid beskrivelsen"
},
"nags.edit-description.title": {
"defaultMessage": "Rediger beskrivelse"
},
@@ -47,12 +47,6 @@
"nags.all-tags-selected.title": {
"defaultMessage": "Wybierz odpowiednie tagi"
},
"nags.description-too-short.description": {
"defaultMessage": "Twój opis składa się z {length, plural, one {# czytenego znaku} other {# czytelnych znaków}}. Zalecane jest użycie chociaż {minChars, plural, one {# znaku} other {# znaków}} by stworzyć przejrzysty i informatywny opis."
},
"nags.description-too-short.title": {
"defaultMessage": "Rozwiń opis"
},
"nags.edit-description.title": {
"defaultMessage": "Edytuj opis"
},
@@ -53,12 +53,6 @@
"nags.check-disclosures.title": {
"defaultMessage": "Verificar divulgações de conteúdo"
},
"nags.description-too-short.description": {
"defaultMessage": "Sua descrição possui {length, plural, one {apenas # caractere legível} other {apenas # caracteres legíveis}}. No mínimo, {minChars, plural, one {# caractere é recomendado} other {# characteres são recomendados}} para criar uma descrição concisa e informativa."
},
"nags.description-too-short.title": {
"defaultMessage": "Expandir a descrição"
},
"nags.edit-description.title": {
"defaultMessage": "Edite a descrição"
},
@@ -41,12 +41,6 @@
"nags.all-tags-selected.title": {
"defaultMessage": "Seleciona etiquetas adequadas"
},
"nags.description-too-short.description": {
"defaultMessage": "A tua descrição tem {length, plural, one {# caractere legível} other {# caracteres legíveis}}. Pelo menos {minChars, plural, one {# caractere} other {# caracteres}} são recomendados para criar uma descrição clara e informativa."
},
"nags.description-too-short.title": {
"defaultMessage": "Expande a descrição"
},
"nags.edit-description.title": {
"defaultMessage": "Edita a descrição"
},
@@ -35,12 +35,6 @@
"nags.all-tags-selected.title": {
"defaultMessage": "Selectează etichete precise"
},
"nags.description-too-short.description": {
"defaultMessage": "Descrierea dumneavoastră este {length, plural, one {# caracter lizibil} other {# caractere lizibile}}. Măcar {minChars, plural, one {# caracter este} other {# caractere sunt}} recomandate pentru a creea o descriere clară și informativă."
},
"nags.description-too-short.title": {
"defaultMessage": "Extinde descrierea"
},
"nags.edit-description.title": {
"defaultMessage": "Editează descrierea"
},
@@ -53,12 +53,6 @@
"nags.check-disclosures.title": {
"defaultMessage": "Проверьте маркировку"
},
"nags.description-too-short.description": {
"defaultMessage": "Описание содержит {length, plural, one {# читаемый символ} few {# читаемых символа} other {# читаемых символов}}. Для ясного и информативного описания рекомендуется не менее {minChars, plural, one {# символа} other {# символов}}."
},
"nags.description-too-short.title": {
"defaultMessage": "Расширьте описание"
},
"nags.edit-description.title": {
"defaultMessage": "Изменить описание"
},
@@ -53,12 +53,6 @@
"nags.check-disclosures.title": {
"defaultMessage": "Proverite obaveštenja o sadržaju"
},
"nags.description-too-short.description": {
"defaultMessage": "Tvoj opis je {length, plural, one {# čitljiv karakter} few {# čitljiva karaktera} other {# čitljivih karaktera}}. Barem {minChars, plural, one {# karakter} other {# karaktera}} je preporučeno kreiranje jasnog i informativnog opisa."
},
"nags.description-too-short.title": {
"defaultMessage": "Proširi opis"
},
"nags.edit-description.title": {
"defaultMessage": "Promeni opis"
},
@@ -53,12 +53,6 @@
"nags.check-disclosures.title": {
"defaultMessage": "Läs innehållsinformation"
},
"nags.description-too-short.description": {
"defaultMessage": "Din beskrivning är {length, plural, one {# tecken} other {# tecken}} lång. Minst {minChars, plural, one {# tecken} other {# tecken}} rekommenderas för en klar och informativ beskrivning."
},
"nags.description-too-short.title": {
"defaultMessage": "Utöka beskrivningen"
},
"nags.edit-description.title": {
"defaultMessage": "Redigera beskrivning"
},
@@ -14,9 +14,6 @@
"nags.add-links.title": {
"defaultMessage": "เพิ่มลิงก์ภายนอก"
},
"nags.description-too-short.title": {
"defaultMessage": "เพิ่มคำอธิบาย"
},
"nags.edit-description.title": {
"defaultMessage": "แก้ไขคำอธิบาย"
},
@@ -53,12 +53,6 @@
"nags.check-disclosures.title": {
"defaultMessage": "İçerikle ilgili uyarıları kontrol edin"
},
"nags.description-too-short.description": {
"defaultMessage": "Açıklamanız {length, plural, one {# okunabilir karakter} other {# okunabilir karakter}} uzunluğunda. Net ve bilgilendirici bir açıklama oluşturmak için en az {minChars, plural, one {# karakter} other {# karakter}} önerilir."
},
"nags.description-too-short.title": {
"defaultMessage": "Açıklamayı genişlet"
},
"nags.edit-description.title": {
"defaultMessage": "Açıklamayı düzenle"
},
@@ -53,12 +53,6 @@
"nags.check-disclosures.title": {
"defaultMessage": "Перевірте маркування вмісту"
},
"nags.description-too-short.description": {
"defaultMessage": "Ваш опис містить {length, plural, one {# видимий символ} few {# видимі символів} other {# видимих символів}}. Радимо щонайменше {minChars, plural, one {# символ} few {# символи} other {# символів}} для чіткого й змістовного опису."
},
"nags.description-too-short.title": {
"defaultMessage": "Доповніть опис"
},
"nags.edit-description.title": {
"defaultMessage": "Відредагуйте опис"
},
@@ -47,12 +47,6 @@
"nags.all-tags-selected.title": {
"defaultMessage": "Chọn các thẻ chính xác"
},
"nags.description-too-short.description": {
"defaultMessage": "Mô tả của bạn hiện có {length, plural, one {# ký tự có thể đọc} other {# ký tự có thể đọc}}. Khuyến nghị nên có ít nhất {minChars, plural, one {# ký tự} other {# ký tự}} để tạo ra một mô tả rõ ràng và đầy đủ thông tin."
},
"nags.description-too-short.title": {
"defaultMessage": "Mở rộng mô tả"
},
"nags.edit-description.title": {
"defaultMessage": "Chỉnh sửa mô tả"
},
@@ -53,12 +53,6 @@
"nags.check-disclosures.title": {
"defaultMessage": "查看内容披露"
},
"nags.description-too-short.description": {
"defaultMessage": "你的描述包含 {length, plural, other {# 个可读字符}}。建议至少包含 {minChars, plural, other {# 个字符}},让描述详尽清晰。"
},
"nags.description-too-short.title": {
"defaultMessage": "扩展描述"
},
"nags.edit-description.title": {
"defaultMessage": "编辑描述"
},
@@ -53,12 +53,6 @@
"nags.check-disclosures.title": {
"defaultMessage": "檢查內容揭露資訊"
},
"nags.description-too-short.description": {
"defaultMessage": "你的描述目前長度為 {length, plural, other {# 個可閱讀字元}}。建議至少輸入 {minChars, plural, other {# 個字元}},以建立清晰且資訊完整的描述。"
},
"nags.description-too-short.title": {
"defaultMessage": "擴展描述"
},
"nags.edit-description.title": {
"defaultMessage": "編輯描述"
},
@@ -9,6 +9,7 @@ import {
hostnameMatchesDomain,
isIpAddress,
} from '../../validators/links/syntax-checks.ts'
import { validateSpam } from '../../validators/spam/index.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import {
evaluateNonStandardText,
@@ -33,6 +34,10 @@ const messages = defineMessages({
id: 'nags.description-too-short.title',
defaultMessage: 'Expand the description',
},
removeSpam: {
id: 'nags.project-description-spam.title',
defaultMessage: 'Remove spam from the description',
},
shortenHeaders: {
id: 'nags.long-headers.title',
defaultMessage: 'Shorten headers',
@@ -69,7 +74,12 @@ const messages = defineMessages({
tooShort: {
id: 'nags.description-too-short.description',
defaultMessage:
'Your description is {length, plural, one {# readable character} other {# readable characters}}. At least {minChars, plural, one {# character} other {# characters}} is recommended to create a clear and informative description.',
"Your description is too brief. Add more to clearly describe the project's purpose and function.",
},
spam: {
id: 'nags.project-description-spam.description',
defaultMessage:
'Repeated characters, words, or phrases cannot be used to pad a project description.',
},
longHeaders: {
id: 'nags.long-headers.description',
@@ -85,7 +95,7 @@ const messages = defineMessages({
export const DESCRIPTION_MAX_PROFANITY_COUNT = 2
export const DESCRIPTION_NON_STANDARD_TEXT_FAILURE_THRESHOLD = 0.05
export const MIN_DESCRIPTION_CHARS = 200
export const MIN_DESCRIPTION_CHARS = 125
export const MAX_HEADER_LENGTH = 80
export const BANNED_DESCRIPTION_LINK_DOMAINS = [...URL_SHORTENERS] as const
@@ -141,21 +151,29 @@ export function analyzeHeaderLength(markdown: string): {
return { hasLongHeaders: longHeaders.length > 0, longHeaders }
}
export function countText(markdown: string): number {
if (!markdown) return 0
export function extractDescriptionText(markdown: string): string {
if (!markdown) return ''
const withoutCode = markdown.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '')
const withoutImagesAndLinks = withoutCode
.replace(/!\[[^\]]*]\([^)]+\)/g, ' ')
.replace(/!\[([^\]]*)]\([^)]+\)/g, '$1')
.replace(/\[[^\]]*]\([^)]+\)/g, ' ')
const withoutHtml = withoutImagesAndLinks.replace(/<[^>]+>/g, ' ')
const withHtmlImageAltText = withoutImagesAndLinks.replace(/<img[^>]*>/gi, (image) => {
const altMatch = image.match(/alt\s*=\s*(?:"([^"]*)"|'([^']*)')/i)
return altMatch?.[1] ?? altMatch?.[2] ?? ' '
})
const withoutHtml = withHtmlImageAltText.replace(/<[^>]+>/g, ' ')
const withoutMarkdownSyntax = withoutHtml
.replace(/^(?:>[ \t]?)+/gm, '')
.replace(/^#{1,6}\s+/gm, ' ')
.replace(/[*_~`>-]/g, ' ')
.replace(/\|/g, ' ')
return withoutMarkdownSyntax.replace(/\s+/g, ' ').trim().length
return withoutMarkdownSyntax.replace(/\s+/g, ' ').trim()
}
export function countText(markdown: string): number {
return extractDescriptionText(markdown).length
}
export function analyzeImageContent(markdown: string): {
@@ -220,6 +238,32 @@ export const projectDescriptionValidationRules = {
nag: { title: messages.addDescription, ...commonNagPresentation },
},
},
'description-too-short': {
severity: 'error',
evaluate: (description) => {
const normalized = normalizeProjectFieldText(description ?? '')
if (!normalized) return { valid: true }
const length = countText(normalized)
return length >= MIN_DESCRIPTION_CHARS
? { valid: true }
: { valid: false, values: { length, minChars: MIN_DESCRIPTION_CHARS } }
},
presentation: {
message: messages.tooShort,
nag: { title: messages.expandDescription, ...commonNagPresentation },
},
},
'project-description-spam': {
severity: 'error',
evaluate: (description) => ({
valid: validateSpam(extractDescriptionText(description ?? '')).valid,
}),
presentation: {
message: messages.spam,
nag: { title: messages.removeSpam, ...commonNagPresentation },
},
},
'project-description-banned-link': {
severity: 'error',
evaluate: (description) => {
@@ -235,23 +279,6 @@ export const projectDescriptionValidationRules = {
nag: { title: messages.fixDescription, ...commonNagPresentation },
},
},
'description-too-short': {
severity: 'warning',
evaluate: (description) => {
const normalized = normalizeProjectFieldText(description ?? '')
if (!normalized) return { valid: true }
const length = countText(normalized)
if (length < MIN_DESCRIPTION_CHARS) {
return { valid: false, values: { length, minChars: MIN_DESCRIPTION_CHARS } }
} else {
return { valid: true }
}
},
presentation: {
message: messages.tooShort,
nag: { title: messages.expandDescription, ...commonNagPresentation },
},
},
'long-headers': {
severity: 'warning',
evaluate: (description) => {
@@ -3,6 +3,7 @@ import LinkifyIt from 'linkify-it'
import tlds from 'tlds' with { type: 'json' }
import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { validateSpam } from '../../validators/spam/index.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import {
evaluateNonStandardText,
@@ -27,6 +28,10 @@ const messages = defineMessages({
id: 'nags.summary-too-short.title',
defaultMessage: 'Expand the summary',
},
removeSpam: {
id: 'nags.project-summary-spam.title',
defaultMessage: 'Remove spam from the summary',
},
cleanUpSummary: {
id: 'nags.summary-special-formatting.title',
defaultMessage: 'Clean up the summary',
@@ -54,7 +59,12 @@ const messages = defineMessages({
tooShort: {
id: 'project.text-validation.summary-too-short',
defaultMessage:
'Your summary is {length, plural, one {# character} other {# characters}}. At least {minChars, plural, one {# character} other {# characters}} is recommended to create an informative and enticing summary.',
'Your summary is too short. Add a sentence or two which describes your project.',
},
spam: {
id: 'nags.project-summary-spam.description',
defaultMessage:
'Repeated characters, words, or phrases cannot be used to pad a project summary.',
},
specialFormatting: {
id: 'nags.summary-special-formatting.description',
@@ -159,6 +169,16 @@ export const projectSummaryValidationRules = {
nag: { title: messages.expandSummary, ...commonNagPresentation },
},
},
'project-summary-spam': {
severity: 'error',
evaluate: ({ summary }) => ({
valid: validateSpam(normalizeProjectFieldText(summary ?? '')).valid,
}),
presentation: {
message: messages.spam,
nag: { title: messages.removeSpam, ...commonNagPresentation },
},
},
'summary-special-formatting': {
severity: 'error',
evaluate: ({ summary }) => ({
@@ -81,14 +81,14 @@ test('collects every matching project name rule', () => {
)
assert.deepEqual(matches[0]?.values, { value: 'fuck' })
assert.equal(matches[1]?.rule.severity, 'error')
assert.equal(matches[2]?.rule.severity, 'warning')
assert.equal(matches[2]?.rule.severity, 'error')
})
test('derives project name field presentation from the matching rule', () => {
assert.deepEqual(validateProjectNameField('Minecraft'), [
{
code: 'minecraft-title-clause',
severity: 'warning',
severity: 'error',
message: {
id: 'nags.minecraft-title-clause.description',
defaultMessage:
@@ -103,7 +103,7 @@ test('derives project name nags from the same matching rule', () => {
const [nag] = toNags(evaluateRules('Minecraft', projectNameValidationRules))
assert.equal(nag?.id, 'minecraft-title-clause')
assert.equal(nag?.status, 'warning')
assert.equal(nag?.status, 'required')
assert.equal(nag?.title.id, 'nags.minecraft-title-clause.title')
assert.equal(nag?.link?.path, 'settings')
assert.equal(nag?.link?.title.id, 'nags.edit-title.title')
@@ -116,7 +116,7 @@ test('validates summary content from one rule set', () => {
({ code, severity }) => ({ code, severity }),
),
[
{ code: 'summary-too-short', severity: 'warning' },
{ code: 'summary-too-short', severity: 'error' },
{ code: 'summary-special-formatting', severity: 'error' },
],
)
@@ -129,6 +129,16 @@ test('validates summary content from one rule set', () => {
)
})
test('rejects repeated summary padding', () => {
assert.deepEqual(
validateProjectSummary({
summary: 'Useful project! '.repeat(3),
name: 'Project title',
}).map(({ code }) => code),
['project-summary-spam'],
)
})
test('rejects every link and IP address in project summaries', () => {
for (const summary of [
'Visit https://example.dev for more information about this project',
@@ -178,7 +188,26 @@ test('validates description requirements and simultaneous recommendations', () =
const description = `${'# '.concat('A'.repeat(81))}\n![](one.png)\n![](two.png)\n![](three.png)\n![](four.png)`
assert.deepEqual(
validateProjectDescription(description).map(({ code }) => code),
['description-too-short', 'long-headers', 'missing-alt-text'],
['description-too-short', 'project-description-spam', 'long-headers', 'missing-alt-text'],
)
})
test('requires 125 readable description characters', () => {
const description =
'This project adds useful tools, flexible behavior, accessible documentation, polished gameplay, and support for every player.'
assert.equal(countText(description), MIN_DESCRIPTION_CHARS)
assert.deepEqual(
validateProjectDescription(description.slice(0, -1)).map(({ code }) => code),
['description-too-short'],
)
assert.deepEqual(validateProjectDescription(description), [])
})
test('rejects repeated description padding', () => {
assert.deepEqual(
validateProjectDescription('Useful project! '.repeat(10)).map(({ code }) => code),
['project-description-spam'],
)
})
@@ -190,10 +219,20 @@ test('requires alt text for description images', () => {
assert.deepEqual(analyzeImageContent('<img src="screenshot.png">'), { hasEmptyAltText: true })
})
test('counts image alt text as readable description text', () => {
assert.equal(countText('![Project screenshot](screenshot.png)'), 'Project screenshot'.length)
assert.equal(
countText('<img src="screenshot.png" alt="Project screenshot">'),
'Project screenshot'.length,
)
})
test('counts blockquote content as readable description text', () => {
assert.equal(countText('> Quoted text'), 'Quoted text'.length)
const quotedDescription = `> ${'A'.repeat(MIN_DESCRIPTION_CHARS)}`
const quotedDescription =
'> This project adds useful tools, flexible behavior, accessible documentation, polished gameplay, and support for every player.'
assert.equal(countText(quotedDescription), MIN_DESCRIPTION_CHARS)
assert.equal(
validateProjectDescription(quotedDescription).some(
({ code }) => code === 'description-too-short',
@@ -0,0 +1,113 @@
export type SpamMatchKind = 'character' | 'word' | 'phrase'
export interface SpamMatch {
kind: SpamMatchKind
value: string
}
export interface SpamResult {
valid: boolean
firstMatch?: SpamMatch
}
export const MIN_REPEATED_CHARACTER_COUNT = 8
export const MIN_REPEATED_WORD_COUNT = 4
export const MIN_REPEATED_PHRASE_COUNT = 3
export const MAX_REPEATED_PHRASE_WORDS = 8
const REPEATABLE_CHARACTER_PATTERN = /\S/u
const WORD_PATTERN = /[\p{L}\p{M}\p{N}]+/gu
function findRepeatedCharacter(text: string): SpamMatch | undefined {
let previousCharacter: string | undefined
let repeatedCount = 0
for (const character of text.normalize('NFC')) {
const normalizedCharacter = character.toLowerCase()
if (
REPEATABLE_CHARACTER_PATTERN.test(normalizedCharacter) &&
normalizedCharacter === previousCharacter
) {
repeatedCount++
} else {
previousCharacter = normalizedCharacter
repeatedCount = REPEATABLE_CHARACTER_PATTERN.test(normalizedCharacter) ? 1 : 0
}
if (repeatedCount >= MIN_REPEATED_CHARACTER_COUNT) {
return { kind: 'character', value: character.repeat(repeatedCount) }
}
}
return undefined
}
function getWords(text: string): string[] {
return [...text.normalize('NFC').toLowerCase().matchAll(WORD_PATTERN)].map((match) => match[0])
}
function findRepeatedWord(words: readonly string[]): SpamMatch | undefined {
let repeatedCount = 1
for (let index = 1; index < words.length; index++) {
repeatedCount = words[index] === words[index - 1] ? repeatedCount + 1 : 1
if (repeatedCount >= MIN_REPEATED_WORD_COUNT) {
return { kind: 'word', value: words[index] }
}
}
return undefined
}
function phrasesMatch(
words: readonly string[],
firstStart: number,
secondStart: number,
size: number,
) {
for (let offset = 0; offset < size; offset++) {
if (words[firstStart + offset] !== words[secondStart + offset]) return false
}
return true
}
function findRepeatedPhrase(words: readonly string[]): SpamMatch | undefined {
const maxPhraseWords = Math.min(
MAX_REPEATED_PHRASE_WORDS,
Math.floor(words.length / MIN_REPEATED_PHRASE_COUNT),
)
for (let phraseWords = maxPhraseWords; phraseWords >= 2; phraseWords--) {
const repeatedWords = phraseWords * MIN_REPEATED_PHRASE_COUNT
for (let start = 0; start + repeatedWords <= words.length; start++) {
let matches = true
for (let repetition = 1; repetition < MIN_REPEATED_PHRASE_COUNT; repetition++) {
if (!phrasesMatch(words, start, start + repetition * phraseWords, phraseWords)) {
matches = false
break
}
}
if (matches) {
return { kind: 'phrase', value: words.slice(start, start + phraseWords).join(' ') }
}
}
}
return undefined
}
/**
The spam validator checks normalized, readable description text in this order:
Characters: the same non-whitespace character repeated 8 times consecutively, e.g. aaaaaaaa.
Words: the same word repeated 4 times consecutively, case-insensitively, e.g. Great great GREAT great.
Phrases: a 28 word phrase repeated 3 times consecutively. Punctuation and capitalization are ignored, so best project, best project! BEST PROJECT is rejected.
*/
export function validateSpam(text: string): SpamResult {
const words = getWords(text)
const firstMatch =
findRepeatedCharacter(text) ?? findRepeatedWord(words) ?? findRepeatedPhrase(words)
return firstMatch ? { valid: false, firstMatch } : { valid: true }
}
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { validateSpam } from './index.ts'
test('allows ordinary prose', () => {
assert.equal(
validateSpam('This project adds a configurable compass for exploring large worlds.').valid,
true,
)
})
test('detects repeated characters', () => {
assert.deepEqual(validateSpam('aaaaaaaa').firstMatch, {
kind: 'character',
value: 'aaaaaaaa',
})
})
test('detects repeated words case-insensitively', () => {
assert.deepEqual(validateSpam('Great great GREAT great').firstMatch, {
kind: 'word',
value: 'great',
})
})
test('detects repeated phrases across punctuation', () => {
assert.deepEqual(validateSpam('best project, best project! BEST PROJECT').firstMatch, {
kind: 'phrase',
value: 'best project',
})
})
test('allows repetition below the spam thresholds', () => {
assert.equal(validateSpam('so so so good good phrase here phrase here').valid, true)
})