Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
9490a9a
Ignore some nags inside code blocks
aecsocket Sep 6, 2026
af438af
fmt
tdgao Sep 14, 2026
ecc1de7
fix: ensure all heading levels are validated
tdgao Sep 8, 2026
68ea458
feat: add new description matches summary with 80% similarity validator
tdgao Sep 8, 2026
f1e7d68
feat: hide publishing checklist for staff when theres no requred nags
tdgao Sep 8, 2026
c549709
feat: only show disclosures suggestion if no disclosures added
tdgao Sep 8, 2026
be4d765
format
tdgao Sep 8, 2026
12a2858
feat: switch language detection to use lingua, and tune for less fals…
tdgao Sep 9, 2026
d0c84cc
refactor: clean up description preprocessing
tdgao Sep 14, 2026
a404017
remove: unnecessary caching
tdgao Sep 14, 2026
1b532fd
feat: tune description block extraction
tdgao Sep 14, 2026
f7ab387
remove outdated test
tdgao Sep 14, 2026
ca1eafe
fmt
tdgao Sep 14, 2026
e55b334
pnpm prepr
tdgao Sep 14, 2026
422fbdc
performance fix + all tags selected fix
aecsocket Sep 15, 2026
b5137d5
fmt
tdgao Sep 15, 2026
5100b9b
fmt 2
tdgao Sep 15, 2026
6fb8432
Ignore some nags inside code blocks
aecsocket Sep 6, 2026
9d13eae
fmt
tdgao Sep 14, 2026
704b42c
feat: external link validation, pass nags in failed project save due …
tdgao Sep 11, 2026
e24982b
remove: block list in repo, replace with blocklist crate
tdgao Sep 11, 2026
51df3dd
fix: publishing checklist story
tdgao Sep 12, 2026
bd95f74
tweak copy
coolbot100s Sep 12, 2026
9f66dad
pnpm prepr
tdgao Sep 14, 2026
90e6785
refactor: link validation network probing
tdgao Sep 14, 2026
d5b01d3
remove: discord invite check and github repo check nags
tdgao Sep 15, 2026
957c21e
feat: move discord invite expired checking to frontend
tdgao Sep 15, 2026
41fce29
typo fix
tdgao Sep 15, 2026
ef192da
fix: tests
tdgao Sep 15, 2026
3d138c2
remove: link network validation from labrinth
tdgao Sep 15, 2026
a85ff2c
feat: add link network validation in frontend
tdgao Sep 15, 2026
3330f30
remove: feature at workspace level
tdgao Sep 15, 2026
31510d2
remove: useless tests and FQN import
tdgao Sep 15, 2026
2a2c95a
remove: default-features=false
tdgao Sep 15, 2026
126de52
fmt
tdgao Sep 15, 2026
b29beb5
fmt
tdgao Sep 15, 2026
faed947
remove: prepare project creation
tdgao Sep 15, 2026
b619fc6
remove: frontend live url checks due to unreliability
tdgao Sep 15, 2026
60dea00
pnpm prepr
tdgao Sep 16, 2026
0605644
Merge branch 'main' into truman/better-external-link-validation
tdgao Sep 16, 2026
96645e1
fmt
tdgao Sep 16, 2026
68c89a7
fix: lint
tdgao Sep 16, 2026
e503a7a
fix: lint
tdgao Sep 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ aws-sdk-s3 = { version = "=1.122.0", default-features = false, features = [
] }
base64 = "0.22.1"
bitflags = "2.9.4"
blocklist = { version = "1.0.0" }
bon = "3.9.3"
bytemuck = "1.24.0"
bytes = "1.10.1"
Expand Down
2 changes: 2 additions & 0 deletions _typos.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ gam = "gam"
consts = "consts"
# short for "Copy"
Cpy = "Cpy"
# NoDerivatives in SPDX license identifiers
ND = "ND"

[default.extend-identifiers]
# Constant from the `zip` crate
Expand Down
2 changes: 1 addition & 1 deletion apps/frontend/src/components/ValidationMessage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ watch(() => props.check, updateDisplayedCheck)
onScopeDispose(() => clearTimeout(debounceTimer))

const validations = computed(() => {
if (validationIsStale.value || projectValidationLoading.value) return []
if (validationIsStale.value) return []

return Array.isArray(displayedCheck.value)
? displayedCheck.value
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { type Nag, nagDefinitions, toProjectNag } from '@modrinth/moderation'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'

import { DEFAULT_FEATURE_FLAGS } from '../../../composables/featureFlags'
import ModerationProjectNags from './ModerationProjectNags.vue'

const categories = [
Expand Down Expand Up @@ -92,7 +93,6 @@ const tags = {

const previewValues = {
count: 3,
domain: 'example.com',
fullUrl: 'https://example.com/prohibited-link',
languageCount: 12,
length: 12,
Expand All @@ -103,7 +103,6 @@ const previewValues = {
tagCount: 9,
tags: '16x|32x',
totalAvailableTags: 20,
type: 'mod',
url: 'https://example.com/prohibited-link',
value: 'example',
}
Expand All @@ -120,47 +119,122 @@ const suggestionKinds = new Set<Labrinth.Projects.v3.NormalizedProjectNagKind>([

const warningKinds = new Set<Labrinth.Projects.v3.NormalizedProjectNagKind>([
'missing-alt-text',
'verify-external-links',
'too-many-languages',
'too-many-tags',
'multiple-resolution-tags',
'moderator-feedback',
])

const previewNags = Object.keys(nagDefinitions).map((kind) => {
function createValidationNag(
kind: Labrinth.Projects.v3.NormalizedProjectNagKind,
details: Labrinth.Projects.v3.ProjectNag['details'] = {},
): Labrinth.Projects.v3.ProjectNag {
return {
kind: kind.replaceAll('-', '_') as Labrinth.Projects.v3.ProjectNagKind,
severity: suggestionKinds.has(kind)
? 'suggestion'
: warningKinds.has(kind)
? 'warning'
: 'required',
details: { ...previewValues, ...details },
}
}

interface NagPreviewVariant {
details?: Labrinth.Projects.v3.ProjectNag['details']
projectType?: string
}

const linkFields = [
'issues',
'source',
'wiki',
'discord',
'site',
'store',
'license',
'description',
'patreon',
'bmac',
'paypal',
'github',
'ko-fi',
'other',
]

const fieldLinkReasons = [
'global_blocklist_match',
'external_blocklist_match',
'wrong_field',
'ip_address',
'malformed',
'not_in_allowlist',
'duplicate',
'unverifiable',
]

const nagVariants: Partial<
Record<Labrinth.Projects.v3.NormalizedProjectNagKind, NagPreviewVariant[]>
> = {
'link-validation': [
{},
...fieldLinkReasons.flatMap((reason) =>
linkFields.map((field, index) => ({
details: {
reason,
field,
other_field: linkFields[(index + 1) % linkFields.length],
},
})),
),
{ details: { reason: 'download', field: 'description' } },
{ details: { reason: 'discord_invite', field: 'discord' } },
{ details: { reason: 'source_repository', field: 'source' } },
],
'upload-gallery-image': [{}, { projectType: 'resourcepack' }, { projectType: 'shader' }],
'long-headers': [{}, { details: { count: 1 } }],
'all-tags-selected': [{}, { details: { totalAvailableTags: 1 } }],
'multiple-resolution-tags': [{}, { details: { count: 1, tags: ['16x'] } }],
'too-many-tags': [{}, { details: { tagCount: 1 } }],
'too-many-tags-server': [{}, { details: { tagCount: 1 } }],
'too-many-languages': [{}, { details: { languageCount: 1 } }],
}

const everyNag: Nag[] = Object.keys(nagDefinitions).flatMap((kind) => {
const normalizedKind = kind as Labrinth.Projects.v3.NormalizedProjectNagKind
const projectNagKind = kind.replaceAll('-', '_') as Labrinth.Projects.v3.ProjectNagKind
const severity: Labrinth.Projects.v3.ProjectNagSeverity = suggestionKinds.has(normalizedKind)
? 'suggestion'
: warningKinds.has(normalizedKind)
? 'warning'
: 'required'
return toProjectNag(
{ kind: projectNagKind, severity, details: previewValues },
previewValues.projectType,
)
return (nagVariants[normalizedKind] ?? [{}]).map((variant, index) => {
const nag = toProjectNag(
createValidationNag(normalizedKind, variant.details),
variant.projectType ?? previewValues.projectType,
)
return { ...nag, id: `${nag.id}:preview:${index}` }
})
})

const everyNag: Nag[] = [
...previewNags,
{
id: 'resubmit-for-review-preview',
title: 'Resubmit for review',
description: () =>
"Your project has been rejected by Modrinth's staff. Address the moderation team's feedback before resubmitting.",
status: 'special-submit-action',
shouldShow: () => true,
link: {
path: 'moderation',
title: 'Visit moderation page',
shouldShow: () => true,
},
},
]
const draftNags = [
'add-icon',
'add-description',
'upload-version',
'select-environment',
'add-links',
'too-many-tags',
'check-disclosures',
] satisfies Labrinth.Projects.v3.NormalizedProjectNagKind[]

const meta = {
title: 'Website/Moderation/PublishingChecklist',
component: ModerationProjectNags,
beforeEach: () => {
const previousFlags = Object.getOwnPropertyDescriptor(globalThis, 'useFeatureFlags')
Object.defineProperty(globalThis, 'useFeatureFlags', {
configurable: true,
value: () => ref({ ...DEFAULT_FEATURE_FLAGS }),
})
return () => {
if (previousFlags) Object.defineProperty(globalThis, 'useFeatureFlags', previousFlags)
else Reflect.deleteProperty(globalThis, 'useFeatureFlags')
}
},
decorators: [
(story) => ({
components: { story },
Expand Down Expand Up @@ -199,6 +273,9 @@ export default meta
type Story = StoryObj<typeof meta>

export const EntirePublishingChecklist: Story = {
args: {
validationNags: draftNags.map((kind) => createValidationNag(kind)),
},
parameters: {
docs: {
description: {
Expand All @@ -211,13 +288,30 @@ export const EntirePublishingChecklist: Story = {
export const EveryNag: Story = {
args: {
nags: everyNag,
validationNags: draftNags.map((kind) => createValidationNag(kind)),
},
parameters: {
docs: {
description: {
story:
'Every publishing-checklist validation nag plus the submit and resubmit actions, including combinations that cannot normally appear together.',
'Every registered nag and its message variants, including link reasons and fields, license errors, gallery project types, and singular/plural copy.',
},
},
},
}

export const RejectedProject: Story = {
args: {
project: createProject('rejected'),
projectV3: createProjectV3('rejected'),
validationNags: [createValidationNag('moderator-feedback')],
},
}

export const WithheldProject: Story = {
args: {
project: createProject('withheld'),
projectV3: createProjectV3('withheld'),
validationNags: [createValidationNag('moderator-feedback')],
},
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@
nag.status === 'suggestion' && 'text-purple',
]"
/>
{{ getFormattedMessage(nag.title) }}
{{ getFormattedMessage(nag.title, nag.values) }}
</span>
<span>
<span
Expand Down Expand Up @@ -160,7 +160,7 @@ interface Props {
validationNags?: Labrinth.Projects.v3.ProjectNag[]
validationLoading?: boolean
validationAvailable?: boolean
refreshValidation?: () => Promise<Labrinth.Projects.v3.ProjectValidationResponse | null>
submitProject: () => Promise<boolean>
currentMember?: Labrinth.Projects.v3.TeamMember | null
collapsed?: boolean
disableHorizontalScroll?: boolean
Expand Down Expand Up @@ -238,7 +238,6 @@ const props = withDefaults(defineProps<Props>(), {

const emit = defineEmits<{
toggleCollapsed: []
setProcessing: [processing: boolean]
}>()

const isProcessing = computed(() => props.project.status === 'processing')
Expand Down Expand Up @@ -384,10 +383,8 @@ const canSubmitForReview = computed(() => {

async function submitForReview() {
if (!canSubmitForReview.value) return
const validation = await props.refreshValidation?.()
if (!validation || validation.nags.some((nag) => nag.severity === 'required')) return
if (!(await props.submitProject())) return
if (!props.collapsed) emit('toggleCollapsed')
emit('setProcessing', true)
await navigateTo(
`/${props.project.project_type}/${props.project.slug ?? props.project.id}/${nagDestinations.moderation.path}`,
)
Expand All @@ -401,16 +398,15 @@ async function submitForReview() {
const applicableNags = computed<Nag[]>(() => {
if (props.nags) return props.nags

const nagsByKind = new Map<
Labrinth.Projects.v3.NormalizedProjectNagKind,
Labrinth.Projects.v3.ProjectNag
>()
const nagsById = new Map<string, Nag>()
for (const nag of props.validationNags) {
const kind = normalizeProjectNagKind(nag.kind)
if (kind && !nagsByKind.has(kind)) nagsByKind.set(kind, nag)
if (!kind) continue
const mapped = toProjectNag(nag, props.project.project_type)
if (!nagsById.has(mapped.id)) nagsById.set(mapped.id, mapped)
}

return [...nagsByKind.values()].map((nag) => toProjectNag(nag, props.project.project_type))
return [...nagsById.values()]
})

function isNagComplete(nag: Nag): boolean {
Expand Down Expand Up @@ -490,7 +486,7 @@ watch(
const actionableNagKeys = new Set(
validationNags
.filter((nag) => nag.severity === 'required' || nag.severity === 'warning')
.map((nag) => `${nag.severity}:${nag.kind}`),
.map((nag) => `${nag.severity}:${nag.kind}:${JSON.stringify(nag.details)}`),
)
const previousNagKeys = previousActionableNagKeys
const hasNewActionableNag =
Expand Down Expand Up @@ -551,11 +547,11 @@ function getNagDescriptionSegments(nag: Nag): { text: string; isUrl: boolean }[]
.map((text) => ({ text, isUrl: /^https?:\/\//i.test(text) }))
}

function getFormattedMessage(message: string | MessageDescriptor): string {
function getFormattedMessage(message: string | MessageDescriptor, values?: Nag['values']): string {
if (typeof message === 'string') {
return message
}
return formatMessage(message)
return formatMessage(message, values)
}
</script>

Expand Down
Loading
Loading