From 27caf336ccde2f159a52392b234b0fabd97db074 Mon Sep 17 00:00:00 2001 From: Calum Date: Sun, 13 Jul 2025 14:34:56 +0100 Subject: [PATCH] fix: links page + add more validations --- apps/frontend/src/pages/[type]/[id].vue | 2 - .../src/pages/[type]/[id]/settings/links.vue | 6 +- packages/moderation/data/nags/description.ts | 108 ++++++++++++++++++ packages/moderation/data/nags/links.ts | 63 ++++++++++ packages/moderation/data/nags/tags.ts | 64 +++++++++++ 5 files changed, 238 insertions(+), 5 deletions(-) create mode 100644 packages/moderation/data/nags/tags.ts diff --git a/apps/frontend/src/pages/[type]/[id].vue b/apps/frontend/src/pages/[type]/[id].vue index 8936a795f..0b51f73bb 100644 --- a/apps/frontend/src/pages/[type]/[id].vue +++ b/apps/frontend/src/pages/[type]/[id].vue @@ -1360,8 +1360,6 @@ const currentMember = computed(() => { }; } - console.log("Current member:", val); - return val; }); diff --git a/apps/frontend/src/pages/[type]/[id]/settings/links.vue b/apps/frontend/src/pages/[type]/[id]/settings/links.vue index 2838ee38d..1eb5ef3d3 100644 --- a/apps/frontend/src/pages/[type]/[id]/settings/links.vue +++ b/apps/frontend/src/pages/[type]/[id]/settings/links.vue @@ -170,17 +170,17 @@ const wikiUrl = ref(props.project.wiki_url); const discordUrl = ref(props.project.discord_url); const isIssuesUrlCommon = computed(() => { - if (issuesUrl.value.trim() === "") return true; + if (issuesUrl.value?.trim().length ?? 0 === 0) return true; return isCommonUrl(issuesUrl.value, commonLinkDomains.issues); }); const isSourceUrlCommon = computed(() => { - if (sourceUrl.value.trim() === "") return true; + if (sourceUrl.value?.trim().length ?? 0 === 0) return true; return isCommonUrl(sourceUrl.value, commonLinkDomains.source); }); const isDiscordUrlCommon = computed(() => { - if (discordUrl.value.trim() === "") return true; + if (discordUrl.value?.trim().length ?? 0 === 0) return true; return isCommonUrl(discordUrl.value, commonLinkDomains.discord); }); diff --git a/packages/moderation/data/nags/description.ts b/packages/moderation/data/nags/description.ts index dedab51c7..a3409738e 100644 --- a/packages/moderation/data/nags/description.ts +++ b/packages/moderation/data/nags/description.ts @@ -3,6 +3,36 @@ import type { Nag, NagContext } from '../../types/nags' export const MIN_DESCRIPTION_CHARS = 500 export const MIN_SUMMARY_CHARS = 125 +function analyzeHeaderLength(markdown: string): { hasLongHeaders: boolean; longHeaders: string[] } { + if (!markdown) return { hasLongHeaders: false, longHeaders: [] } + + const withoutCodeBlocks = markdown.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '') + + const headerRegex = /^(#{1,3})\s+(.+)$/gm + const headers = [...withoutCodeBlocks.matchAll(headerRegex)] + + const longHeaders: string[] = [] + + headers.forEach((match) => { + const headerText = match[2].trim() + const sentenceEnders = /[.!?]+/g + const sentences = headerText.split(sentenceEnders).filter((s) => s.trim().length > 0) + + const hasSentenceEnders = sentenceEnders.test(headerText) + const isVeryLong = headerText.length > 100 + const hasMultipleSentences = sentences.length > 1 + + if (hasSentenceEnders || isVeryLong || hasMultipleSentences) { + longHeaders.push(headerText) + } + }) + + return { + hasLongHeaders: longHeaders.length > 0, + longHeaders, + } +} + function analyzeImageContent(markdown: string): { imageHeavy: boolean; hasEmptyAltText: boolean } { if (!markdown) return { imageHeavy: false, hasEmptyAltText: false } @@ -53,6 +83,26 @@ export const descriptionNags: Nag[] = [ shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description', }, }, + { + id: 'long-headers', + title: 'Headers are too long', + description: (context: NagContext) => { + const { longHeaders } = analyzeHeaderLength(context.project.body || '') + const count = longHeaders.length + + return `${count} header${count > 1 ? 's' : ''} in your description ${count > 1 ? 'are' : 'is'} too long. Headers should be concise and act as section titles, not full sentences.` + }, + status: 'warning', + shouldShow: (context: NagContext) => { + const { hasLongHeaders } = analyzeHeaderLength(context.project.body || '') + return hasLongHeaders + }, + 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', @@ -69,6 +119,64 @@ export const descriptionNags: Nag[] = [ shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings', }, }, + { + id: 'minecraft-title-clause', + title: 'Title contains "Minecraft"', + description: (context: NagContext) => + `Please remove "Minecraft" from your title. You cannot use "Minecraft" in your title for legal reasons.`, + status: 'required', + shouldShow: (context: NagContext) => { + const title = context.project.title?.toLowerCase() || '' + return title.includes('minecraft') && title.length > 0 + }, + link: { + path: 'settings', + title: 'Edit title', + shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings', + }, + }, + { + id: 'title-contains-technical-info', + title: 'Title contains loader or version info', + description: (context: NagContext) => { + return `Removing these helps keep titles clean and makes your project easier to find. Version and loader information is automatically displayed alongside your project.` + }, + status: 'warning', + shouldShow: (context: NagContext) => { + const title = context.project.title?.toLowerCase() || '' + if (!title) return false + + const loaderNames = + context.tags.loaders?.map((loader: any) => loader.name?.toLowerCase()) || [] + const hasLoader = loaderNames.some((loader) => loader && title.includes(loader.toLowerCase())) + const versionPatterns = [/\b1\.\d+(\.\d+)?\b/] + const hasVersionPattern = versionPatterns.some((pattern) => pattern.test(title)) + + return hasLoader || hasVersionPattern + }, + link: { + path: 'settings', + title: 'Edit title', + shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings', + }, + }, + { + id: 'summary-same-as-title', + title: 'Summary is project name', + description: (context: NagContext) => + `Your summary is the same as your project name. Please change it. It's recommended to have a unique summary to provide more context about your project.`, + status: 'required', + shouldShow: (context: NagContext) => { + const title = context.project.title?.trim() || '' + const summary = context.project.description?.trim() || '' + return title === summary && title.length > 0 && summary.length > 0 + }, + link: { + path: 'settings', + title: 'Edit summary', + shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings', + }, + }, { id: 'image-heavy-description', title: 'Description is mostly images', diff --git a/packages/moderation/data/nags/links.ts b/packages/moderation/data/nags/links.ts index c1d2ffdff..c23d0c3ec 100644 --- a/packages/moderation/data/nags/links.ts +++ b/packages/moderation/data/nags/links.ts @@ -5,6 +5,25 @@ 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'], + licenseBlocklist: [ + 'youtube.com', + 'youtu.be', + 'modrinth.com', + 'curseforge.com', + 'twitter.com', + 'x.com', + 'discord.gg', + 'discord.com', + 'instagram.com', + 'facebook.com', + 'tiktok.com', + 'reddit.com', + 'twitch.tv', + 'patreon.com', + 'ko-fi.com', + 'paypal.com', + 'buymeacoffee.com', + ], } export function isCommonUrl(url: string | undefined, commonDomains: string[]): boolean { @@ -18,6 +37,16 @@ export function isCommonUrl(url: string | undefined, commonDomains: string[]): b } } +export function isUncommonLicenseUrl(url: string | undefined, domains: string[]): boolean { + if (!url) return false + try { + const domain = new URL(url).hostname.toLowerCase() + return domains.some((uncommonDomain) => domain.includes(uncommonDomain)) + } catch { + return false + } +} + export const linksNags: Nag[] = [ { id: 'verify-external-links', @@ -38,6 +67,40 @@ export const linksNags: Nag[] = [ shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-links', }, }, + { + id: 'invalid-license-url', + title: 'Invalid license URL', + description: (context: NagContext) => { + const licenseUrl = context.project.license.url + if (!licenseUrl) return 'License URL is invalid.' + + try { + const domain = new URL(licenseUrl).hostname.toLowerCase() + return `Your license URL points to ${domain}, which is not appropriate for license information. License URLs should link to the actual license text or legal documentation, not social media, gaming platforms etc.` + } catch { + return 'Your license URL appears to be malformed. Please provide a valid URL to your license text.' + } + }, + status: 'required', + shouldShow: (context: NagContext) => { + const licenseUrl = context.project.license.url + if (!licenseUrl) return false + + const isBlocklisted = isUncommonLicenseUrl(licenseUrl, commonLinkDomains.licenseBlocklist) + + try { + new URL(licenseUrl) + return isBlocklisted + } catch { + return true + } + }, + link: { + path: 'settings', + title: 'Edit license', + shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings', + }, + }, { id: 'gpl-license-source-required', title: 'GPL license requires source', diff --git a/packages/moderation/data/nags/tags.ts b/packages/moderation/data/nags/tags.ts new file mode 100644 index 000000000..016ac1de5 --- /dev/null +++ b/packages/moderation/data/nags/tags.ts @@ -0,0 +1,64 @@ +import type { Nag, NagContext } from '../../types/nags' + +export const tagsNags: Nag[] = [ + { + id: 'too-many-tags', + title: 'Too many tags selected', + description: (context: NagContext) => { + const tagCount = context.project.categories.length + return `You've selected ${tagCount} tags. Consider reducing to 3 or fewer to keep your project focused and easier to discover.` + }, + status: 'warning', + shouldShow: (context: NagContext) => { + return context.project.categories.length > 3 + }, + link: { + path: 'settings/tags', + title: 'Edit tags', + shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-tags', + }, + }, + { + id: 'multiple-resolution-tags', + title: 'Multiple resolution tags selected', + description: (context: NagContext) => { + const resolutionTags = context.project.categories.filter((tag: string) => + ['16x', '32x', '48x', '64x', '128x', '256x', '512x', '1024x'].includes(tag), + ) + return `You've selected ${resolutionTags.length} resolution tags (${resolutionTags.join(', ')}). Resource packs should typically only have one resolution tag that matches their primary resolution.` + }, + status: 'warning', + shouldShow: (context: NagContext) => { + if (context.project.project_type !== 'resourcepack') return false + + const resolutionTags = context.project.categories.filter((tag: string) => + ['16x', '32x', '48x', '64x', '128x', '256x', '512x', '1024x'].includes(tag), + ) + return resolutionTags.length > 1 + }, + link: { + path: 'settings/tags', + title: 'Edit tags', + shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-tags', + }, + }, + { + id: 'all-tags-selected', + title: 'All tags selected', + description: (context: NagContext) => { + const totalAvailableTags = context.tags.categories?.length || 0 + return `You've selected all ${totalAvailableTags} available tags. This defeats the purpose of tags, which are meant to help users find relevant projects. Please select only the tags that truly apply to your project.` + }, + status: 'required', + shouldShow: (context: NagContext) => { + const totalAvailableTags = context.tags.categories?.length || 0 + const selectedTags = context.project.categories.length + return totalAvailableTags > 0 && selectedTags === totalAvailableTags + }, + link: { + path: 'settings/tags', + title: 'Edit tags', + shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-tags', + }, + }, +]