feat: start work implementing validation checks using new nag system
This commit is contained in:
@@ -22,6 +22,10 @@
|
|||||||
"
|
"
|
||||||
:on-image-upload="onUploadHandler"
|
:on-image-upload="onUploadHandler"
|
||||||
/>
|
/>
|
||||||
|
<div v-if="descriptionWarning" class="flex items-center gap-1.5 text-orange">
|
||||||
|
<TriangleAlertIcon class="my-auto" />
|
||||||
|
{{ descriptionWarning }}
|
||||||
|
</div>
|
||||||
<div class="input-group markdown-disclaimer">
|
<div class="input-group markdown-disclaimer">
|
||||||
<button
|
<button
|
||||||
:disabled="!hasChanges"
|
:disabled="!hasChanges"
|
||||||
@@ -38,7 +42,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { SaveIcon } from "@modrinth/assets";
|
import { SaveIcon, TriangleAlertIcon } from "@modrinth/assets";
|
||||||
|
import { MIN_DESCRIPTION_CHARS } from "@modrinth/moderation";
|
||||||
import { MarkdownEditor } from "@modrinth/ui";
|
import { MarkdownEditor } from "@modrinth/ui";
|
||||||
import { type Project, type TeamMember, TeamMemberPermission } from "@modrinth/utils";
|
import { type Project, type TeamMember, TeamMemberPermission } from "@modrinth/utils";
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
@@ -53,6 +58,17 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const description = ref(props.project.body);
|
const description = ref(props.project.body);
|
||||||
|
|
||||||
|
const descriptionWarning = computed(() => {
|
||||||
|
const text = description.value?.trim() || "";
|
||||||
|
const charCount = text.length;
|
||||||
|
|
||||||
|
if (charCount < MIN_DESCRIPTION_CHARS) {
|
||||||
|
return `It's recommended to have a description with at least ${MIN_DESCRIPTION_CHARS} characters. (${charCount}/${MIN_DESCRIPTION_CHARS})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
const patchRequestPayload = computed(() => {
|
const patchRequestPayload = computed(() => {
|
||||||
const payload: {
|
const payload: {
|
||||||
body?: string;
|
body?: string;
|
||||||
|
|||||||
@@ -82,6 +82,10 @@
|
|||||||
<label for="project-summary">
|
<label for="project-summary">
|
||||||
<span class="label__title">Summary</span>
|
<span class="label__title">Summary</span>
|
||||||
</label>
|
</label>
|
||||||
|
<div v-if="summaryWarning" class="my-2 flex items-center gap-1.5 text-orange">
|
||||||
|
<TriangleAlertIcon class="my-auto" />
|
||||||
|
{{ summaryWarning }}
|
||||||
|
</div>
|
||||||
<div class="textarea-wrapper summary-input">
|
<div class="textarea-wrapper summary-input">
|
||||||
<textarea
|
<textarea
|
||||||
id="project-summary"
|
id="project-summary"
|
||||||
@@ -240,10 +244,19 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { formatProjectStatus, formatProjectType } from "@modrinth/utils";
|
import { formatProjectStatus, formatProjectType } from "@modrinth/utils";
|
||||||
import { UploadIcon, SaveIcon, TrashIcon, XIcon, IssuesIcon, CheckIcon } from "@modrinth/assets";
|
import {
|
||||||
|
UploadIcon,
|
||||||
|
SaveIcon,
|
||||||
|
TrashIcon,
|
||||||
|
XIcon,
|
||||||
|
IssuesIcon,
|
||||||
|
CheckIcon,
|
||||||
|
TriangleAlertIcon,
|
||||||
|
} from "@modrinth/assets";
|
||||||
import { Multiselect } from "vue-multiselect";
|
import { Multiselect } from "vue-multiselect";
|
||||||
import { ConfirmModal, Avatar } from "@modrinth/ui";
|
import { ConfirmModal, Avatar } from "@modrinth/ui";
|
||||||
import FileInput from "~/components/ui/FileInput.vue";
|
import FileInput from "~/components/ui/FileInput.vue";
|
||||||
|
import { MIN_SUMMARY_CHARS } from "@modrinth/moderation";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
project: {
|
project: {
|
||||||
@@ -300,6 +313,17 @@ const hasDeletePermission = computed(() => {
|
|||||||
return (props.currentMember.permissions & DELETE_PROJECT) === DELETE_PROJECT;
|
return (props.currentMember.permissions & DELETE_PROJECT) === DELETE_PROJECT;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const summaryWarning = computed(() => {
|
||||||
|
const text = summary.value?.trim() || "";
|
||||||
|
const charCount = text.length;
|
||||||
|
|
||||||
|
if (charCount < MIN_SUMMARY_CHARS) {
|
||||||
|
return `It's recommended to have a summary with at least ${MIN_SUMMARY_CHARS} characters. (${charCount}/${MIN_SUMMARY_CHARS})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
const sideTypes = ["required", "optional", "unsupported"];
|
const sideTypes = ["required", "optional", "unsupported"];
|
||||||
|
|
||||||
const patchData = computed(() => {
|
const patchData = computed(() => {
|
||||||
|
|||||||
@@ -7,11 +7,16 @@
|
|||||||
id="project-issue-tracker"
|
id="project-issue-tracker"
|
||||||
title="A place for users to report bugs, issues, and concerns about your project."
|
title="A place for users to report bugs, issues, and concerns about your project."
|
||||||
>
|
>
|
||||||
<span class="label__title">Issue tracker</span>
|
<span class="label__title">Issue tracker </span>
|
||||||
<span class="label__description">
|
<span class="label__description">
|
||||||
A place for users to report bugs, issues, and concerns about your project.
|
A place for users to report bugs, issues, and concerns about your project.
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
<TriangleAlertIcon
|
||||||
|
v-if="!isIssuesUrlCommon"
|
||||||
|
class="size-6 animate-pulse text-orange"
|
||||||
|
v-tooltip="`You're using a link which isn't common for this link type.`"
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
id="project-issue-tracker"
|
id="project-issue-tracker"
|
||||||
v-model="issuesUrl"
|
v-model="issuesUrl"
|
||||||
@@ -26,11 +31,16 @@
|
|||||||
id="project-source-code"
|
id="project-source-code"
|
||||||
title="A page/repository containing the source code for your project"
|
title="A page/repository containing the source code for your project"
|
||||||
>
|
>
|
||||||
<span class="label__title">Source code</span>
|
<span class="label__title">Source code </span>
|
||||||
<span class="label__description">
|
<span class="label__description">
|
||||||
A page/repository containing the source code for your project
|
A page/repository containing the source code for your project
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
<TriangleAlertIcon
|
||||||
|
v-if="!isSourceUrlCommon"
|
||||||
|
class="size-6 animate-pulse text-orange"
|
||||||
|
v-tooltip="`You're using a link which isn't common for this link type.`"
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
id="project-source-code"
|
id="project-source-code"
|
||||||
v-model="sourceUrl"
|
v-model="sourceUrl"
|
||||||
@@ -61,9 +71,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="adjacent-input">
|
<div class="adjacent-input">
|
||||||
<label id="project-discord-invite" title="An invitation link to your Discord server.">
|
<label id="project-discord-invite" title="An invitation link to your Discord server.">
|
||||||
<span class="label__title">Discord invite</span>
|
<span class="label__title">Discord invite </span>
|
||||||
<span class="label__description"> An invitation link to your Discord server. </span>
|
<span class="label__description"> An invitation link to your Discord server. </span>
|
||||||
</label>
|
</label>
|
||||||
|
<TriangleAlertIcon
|
||||||
|
v-if="!isDiscordUrlCommon"
|
||||||
|
class="size-6 animate-pulse text-orange"
|
||||||
|
v-tooltip="`You're using a link which isn't common for this link type.`"
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
id="project-discord-invite"
|
id="project-discord-invite"
|
||||||
v-model="discordUrl"
|
v-model="discordUrl"
|
||||||
@@ -123,7 +138,8 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { DropdownSelect } from "@modrinth/ui";
|
import { DropdownSelect } from "@modrinth/ui";
|
||||||
import { SaveIcon } from "@modrinth/assets";
|
import { SaveIcon, TriangleAlertIcon } from "@modrinth/assets";
|
||||||
|
import { isCommonUrl, commonLinkDomains } from "@modrinth/moderation";
|
||||||
|
|
||||||
const tags = useTags();
|
const tags = useTags();
|
||||||
|
|
||||||
@@ -153,6 +169,21 @@ const sourceUrl = ref(props.project.source_url);
|
|||||||
const wikiUrl = ref(props.project.wiki_url);
|
const wikiUrl = ref(props.project.wiki_url);
|
||||||
const discordUrl = ref(props.project.discord_url);
|
const discordUrl = ref(props.project.discord_url);
|
||||||
|
|
||||||
|
const isIssuesUrlCommon = computed(() => {
|
||||||
|
if (issuesUrl.value.trim() === "") return true;
|
||||||
|
return isCommonUrl(issuesUrl.value, commonLinkDomains.issues);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isSourceUrlCommon = computed(() => {
|
||||||
|
if (sourceUrl.value.trim() === "") return true;
|
||||||
|
return isCommonUrl(sourceUrl.value, commonLinkDomains.source);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isDiscordUrlCommon = computed(() => {
|
||||||
|
if (discordUrl.value.trim() === "") return true;
|
||||||
|
return isCommonUrl(discordUrl.value, commonLinkDomains.discord);
|
||||||
|
});
|
||||||
|
|
||||||
const rawDonationLinks = JSON.parse(JSON.stringify(props.project.donation_urls));
|
const rawDonationLinks = JSON.parse(JSON.stringify(props.project.donation_urls));
|
||||||
rawDonationLinks.push({
|
rawDonationLinks.push({
|
||||||
id: null,
|
id: null,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { Nag } from '../types/nags'
|
import type { Nag } from '../types/nags'
|
||||||
import { coreNags } from './nags/core'
|
import { coreNags } from './nags/core'
|
||||||
|
import { descriptionNags } from './nags/description'
|
||||||
|
import { linksNags } from './nags/links'
|
||||||
|
|
||||||
export default [...coreNags] as Nag[]
|
export default [...coreNags, ...linksNags, ...descriptionNags] as Nag[]
|
||||||
|
|||||||
104
packages/moderation/data/nags/description.ts
Normal file
104
packages/moderation/data/nags/description.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import type { Nag, NagContext } from '../../types/nags'
|
||||||
|
|
||||||
|
export const MIN_DESCRIPTION_CHARS = 500
|
||||||
|
export const MIN_SUMMARY_CHARS = 125
|
||||||
|
|
||||||
|
function analyzeImageContent(markdown: string): { imageHeavy: boolean; hasEmptyAltText: boolean } {
|
||||||
|
if (!markdown) return { imageHeavy: false, hasEmptyAltText: false }
|
||||||
|
|
||||||
|
const withoutCodeBlocks = markdown.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '')
|
||||||
|
|
||||||
|
const imageRegex = /!\[([^\]]*)\]\([^)]+\)/g
|
||||||
|
const images = [...withoutCodeBlocks.matchAll(imageRegex)]
|
||||||
|
|
||||||
|
const htmlImageRegex = /<img[^>]*>/gi
|
||||||
|
const htmlImages = [...withoutCodeBlocks.matchAll(htmlImageRegex)]
|
||||||
|
|
||||||
|
const totalImages = images.length + htmlImages.length
|
||||||
|
if (totalImages === 0) return { imageHeavy: false, hasEmptyAltText: false }
|
||||||
|
|
||||||
|
const textWithoutImages = withoutCodeBlocks
|
||||||
|
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '')
|
||||||
|
.replace(/<img[^>]*>/gi, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
const textLength = textWithoutImages.length
|
||||||
|
const imageHeavy = textLength < 100 || (totalImages >= 3 && textLength < 200)
|
||||||
|
|
||||||
|
const hasEmptyAltText =
|
||||||
|
images.some((match) => !match[1]?.trim()) ||
|
||||||
|
htmlImages.some((match) => {
|
||||||
|
const altMatch = match[0].match(/alt\s*=\s*["']([^"']*)["']/i)
|
||||||
|
return !altMatch || !altMatch[1]?.trim()
|
||||||
|
})
|
||||||
|
|
||||||
|
return { imageHeavy, hasEmptyAltText }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const descriptionNags: Nag[] = [
|
||||||
|
{
|
||||||
|
id: 'description-too-short',
|
||||||
|
title: 'Description may be insufficient',
|
||||||
|
description: (context: NagContext) =>
|
||||||
|
`Your description is ${context.project.body?.length || 0} characters. It's recommended to have at least ${MIN_DESCRIPTION_CHARS} characters to provide users with enough information about your project.`,
|
||||||
|
status: 'warning',
|
||||||
|
shouldShow: (context: NagContext) => {
|
||||||
|
const bodyLength = context.project.body?.trim()?.length || 0
|
||||||
|
return bodyLength < MIN_DESCRIPTION_CHARS && bodyLength !== 0
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
path: 'settings/description',
|
||||||
|
title: 'Edit description',
|
||||||
|
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'summary-too-short',
|
||||||
|
title: 'Summary may be insufficient',
|
||||||
|
description: (context: NagContext) =>
|
||||||
|
`Your summary is ${context.project.description?.length || 0} characters. It's recommended to have at least ${MIN_SUMMARY_CHARS} characters to provide users with enough information about your project.`,
|
||||||
|
status: 'warning',
|
||||||
|
shouldShow: (context: NagContext) => {
|
||||||
|
const summaryLength = context.project.description?.trim()?.length || 0
|
||||||
|
return summaryLength < MIN_SUMMARY_CHARS && summaryLength !== 0
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
path: 'settings',
|
||||||
|
title: 'Edit summary',
|
||||||
|
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'image-heavy-description',
|
||||||
|
title: 'Description is mostly images',
|
||||||
|
description: () =>
|
||||||
|
`Please add more descriptive text to help users understand your project, especially those using screen readers or with slow internet connections.`,
|
||||||
|
status: 'warning',
|
||||||
|
shouldShow: (context: NagContext) => {
|
||||||
|
const { imageHeavy } = analyzeImageContent(context.project.body || '')
|
||||||
|
return imageHeavy
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
path: 'settings/description',
|
||||||
|
title: 'Edit description',
|
||||||
|
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'missing-alt-text',
|
||||||
|
title: 'Images missing alt text',
|
||||||
|
description: () =>
|
||||||
|
`Some of your images are missing alt text, which is important for accessibility, especially for visually impaired users.`,
|
||||||
|
status: 'warning',
|
||||||
|
shouldShow: (context: NagContext) => {
|
||||||
|
const { hasEmptyAltText } = analyzeImageContent(context.project.body || '')
|
||||||
|
return hasEmptyAltText
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
path: 'settings/description',
|
||||||
|
title: 'Edit description',
|
||||||
|
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
3
packages/moderation/data/nags/index.ts
Normal file
3
packages/moderation/data/nags/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './core'
|
||||||
|
export * from './links'
|
||||||
|
export * from './description'
|
||||||
82
packages/moderation/data/nags/links.ts
Normal file
82
packages/moderation/data/nags/links.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import type { Nag, NagContext } from '../../types/nags'
|
||||||
|
import { formatProjectType } from '@modrinth/utils'
|
||||||
|
|
||||||
|
export const commonLinkDomains = {
|
||||||
|
source: ['github.com', 'gitlab.com', 'bitbucket.org', 'codeberg.org', 'git.sr.ht'],
|
||||||
|
issues: ['github.com', 'gitlab.com', 'bitbucket.org', 'codeberg.org'],
|
||||||
|
discord: ['discord.gg', 'discord.com'],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCommonUrl(url: string | undefined, commonDomains: string[]): boolean {
|
||||||
|
if (!url) return false
|
||||||
|
try {
|
||||||
|
const domain = new URL(url).hostname.toLowerCase()
|
||||||
|
console.log(domain)
|
||||||
|
return commonDomains.some((allowed) => domain.includes(allowed))
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const linksNags: Nag[] = [
|
||||||
|
{
|
||||||
|
id: 'verify-external-links',
|
||||||
|
title: 'Verify external links',
|
||||||
|
description: () =>
|
||||||
|
`Some of your external links may be using domains that aren't recognized as common for their link type.`,
|
||||||
|
status: 'warning',
|
||||||
|
shouldShow: (context: NagContext) => {
|
||||||
|
return (
|
||||||
|
!isCommonUrl(context.project.source_url, commonLinkDomains.source) ||
|
||||||
|
!isCommonUrl(context.project.issues_url, commonLinkDomains.issues) ||
|
||||||
|
!isCommonUrl(context.project.discord_url, commonLinkDomains.discord)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
path: 'settings/links',
|
||||||
|
title: 'Visit links settings',
|
||||||
|
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-links',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gpl-license-source-required',
|
||||||
|
title: 'GPL license requires source',
|
||||||
|
description: (context: NagContext) =>
|
||||||
|
`Your ${formatProjectType(context.project.project_type).toLowerCase()} uses a GPL license which requires source code to be available. Please provide a source code link or consider using a different license.`,
|
||||||
|
status: 'required',
|
||||||
|
shouldShow: (context: NagContext) => {
|
||||||
|
const gplLicenses = [
|
||||||
|
'GPL-2.0',
|
||||||
|
'GPL-2.0+',
|
||||||
|
'GPL-2.0-only',
|
||||||
|
'GPL-2.0-or-later',
|
||||||
|
'GPL-3.0',
|
||||||
|
'GPL-3.0+',
|
||||||
|
'GPL-3.0-only',
|
||||||
|
'GPL-3.0-or-later',
|
||||||
|
'LGPL-2.1',
|
||||||
|
'LGPL-2.1+',
|
||||||
|
'LGPL-2.1-only',
|
||||||
|
'LGPL-2.1-or-later',
|
||||||
|
'LGPL-3.0',
|
||||||
|
'LGPL-3.0+',
|
||||||
|
'LGPL-3.0-only',
|
||||||
|
'LGPL-3.0-or-later',
|
||||||
|
'AGPL-3.0',
|
||||||
|
'AGPL-3.0+',
|
||||||
|
'AGPL-3.0-only',
|
||||||
|
'AGPL-3.0-or-later',
|
||||||
|
]
|
||||||
|
|
||||||
|
const isGplLicense = gplLicenses.includes(context.project.license.id)
|
||||||
|
const hasSourceUrl = !!context.project.source_url
|
||||||
|
|
||||||
|
return isGplLicense && !hasSourceUrl
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
path: 'settings/links',
|
||||||
|
title: 'Visit links settings',
|
||||||
|
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-links',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -5,6 +5,7 @@ export * from './types/keybinds'
|
|||||||
export * from './types/nags'
|
export * from './types/nags'
|
||||||
export * from './utils'
|
export * from './utils'
|
||||||
|
|
||||||
|
export * from './data/nags/index'
|
||||||
export { default as checklist } from './data/checklist'
|
export { default as checklist } from './data/checklist'
|
||||||
export { default as keybinds } from './data/keybinds'
|
export { default as keybinds } from './data/keybinds'
|
||||||
export { default as nags } from './data/nags'
|
export { default as nags } from './data/nags'
|
||||||
|
|||||||
Reference in New Issue
Block a user