Compare commits

..

9 Commits

Author SHA1 Message Date
coolbot100s
909250188b add Description clarity button 2025-08-01 13:15:18 -07:00
coolbot100s
240e8a6a93 reorder link text to match the settings page 2025-07-31 21:31:09 -07:00
coolbot100s
b625ed11b7 Update license messages to use flink 2025-07-31 21:15:53 -07:00
coolbot100s
9bf031d013 Tweak summary formatting msg 2025-07-30 18:28:35 -07:00
coolbot
9ec3d8ea3c Update versions.ts
remove unnecessary import

Signed-off-by: coolbot <76798835+coolbot100s@users.noreply.github.com>
2025-07-30 00:19:40 -07:00
coolbot100s
e3105b3f98 redist libs message altered, and now shows on plugins too 2025-07-29 23:59:42 -07:00
coolbot100s
85e47e60e4 Move status alerts to top of message, getting rid of separators. 2025-07-29 23:06:41 -07:00
coolbot100s
8c00665d94 Only show slug stuff when needed. 2025-07-29 22:15:13 -07:00
coolbot100s
e15347e7c7 oop, all commas! 2025-07-29 21:26:03 -07:00
55 changed files with 839 additions and 3164 deletions

3
.idea/code.iml generated
View File

@@ -10,10 +10,11 @@
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/tests" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/packages/app-lib/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/rust-common/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/ariadne/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
</module>

View File

@@ -9,7 +9,7 @@
"tsc:check": "vue-tsc --noEmit",
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"src/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/helpers,src/pages,src/store}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"test": "vue-tsc --noEmit"
},
"dependencies": {

View File

@@ -21,11 +21,14 @@ const props = defineProps({
})
const featuredCategory = computed(() => {
if (props.project.display_categories.includes('optimization')) {
if (props.project.categories.includes('optimization')) {
return 'optimization'
}
return props.project.display_categories[0] ?? props.project.categories[0]
if (props.project.categories.length > 0) {
return props.project.categories[0]
}
return undefined
})
const toColor = computed(() => {

View File

@@ -76,10 +76,10 @@ const installing = ref(false)
const onInstall = ref(() => {})
defineExpose({
show: (instanceVal, projectVal, projectVersions, selected, callback) => {
show: (instanceVal, projectVal, projectVersions, callback) => {
instance.value = instanceVal
versions.value = projectVersions
selectedVersion.value = selected ?? projectVersions[0]
selectedVersion.value = projectVersions[0]
project.value = projectVal

View File

@@ -6,8 +6,9 @@ import type {
ServerWorld,
SingleplayerWorld,
World,
set_world_display_status,
getWorldIdentifier,
} from '@/helpers/worlds.ts'
import { set_world_display_status, getWorldIdentifier } from '@/helpers/worlds.ts'
import { formatNumber, getPingLevel } from '@modrinth/utils'
import {
useRelativeTime,
@@ -60,8 +61,7 @@ const props = withDefaults(
playingInstance?: boolean
playingWorld?: boolean
startingInstance?: boolean
supportsServerQuickPlay?: boolean
supportsWorldQuickPlay?: boolean
supportsQuickPlay?: boolean
currentProtocol?: ProtocolVersion | null
highlighted?: boolean
@@ -85,8 +85,7 @@ const props = withDefaults(
playingInstance: false,
playingWorld: false,
startingInstance: false,
supportsServerQuickPlay: true,
supportsWorldQuickPlay: false,
supportsQuickPlay: false,
currentProtocol: null,
refreshing: false,
@@ -129,13 +128,9 @@ const messages = defineMessages({
id: 'instance.worlds.a_minecraft_server',
defaultMessage: 'A Minecraft Server',
},
noServerQuickPlay: {
id: 'instance.worlds.no_server_quick_play',
defaultMessage: 'You can only jump straight into servers on Minecraft Alpha 1.0.5+',
},
noSingleplayerQuickPlay: {
id: 'instance.worlds.no_singleplayer_quick_play',
defaultMessage: 'You can only jump straight into singleplayer worlds on Minecraft 1.20+',
noQuickPlay: {
id: 'instance.worlds.no_quick_play',
defaultMessage: 'You can only jump straight into worlds on Minecraft 1.20+',
},
gameAlreadyOpen: {
id: 'instance.worlds.game_already_open',
@@ -157,6 +152,10 @@ const messages = defineMessages({
id: 'instance.worlds.view_instance',
defaultMessage: 'View instance',
},
playAnyway: {
id: 'instance.worlds.play_anyway',
defaultMessage: 'Play anyway',
},
playInstance: {
id: 'instance.worlds.play_instance',
defaultMessage: 'Play instance',
@@ -331,24 +330,17 @@ const messages = defineMessages({
<ButtonStyled v-else>
<button
v-tooltip="
world.type == 'server' && !supportsServerQuickPlay
? formatMessage(messages.noServerQuickPlay)
: world.type == 'singleplayer' && !supportsWorldQuickPlay
? formatMessage(messages.noSingleplayerQuickPlay)
: playingOtherWorld || locked
? formatMessage(messages.gameAlreadyOpen)
: !serverStatus
? formatMessage(messages.noContact)
: serverIncompatible
? formatMessage(messages.incompatibleServer)
: null
"
:disabled="
playingOtherWorld ||
startingInstance ||
(world.type == 'server' && !supportsServerQuickPlay) ||
(world.type == 'singleplayer' && !supportsWorldQuickPlay)
!serverStatus
? formatMessage(messages.noContact)
: serverIncompatible
? formatMessage(messages.incompatibleServer)
: !supportsQuickPlay
? formatMessage(messages.noQuickPlay)
: playingOtherWorld || locked
? formatMessage(messages.gameAlreadyOpen)
: null
"
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
@click="emit('play')"
>
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
@@ -365,6 +357,11 @@ const messages = defineMessages({
disabled: playingInstance,
action: () => emit('play-instance'),
},
{
id: 'play-anyway',
shown: serverIncompatible && !playingInstance && supportsQuickPlay,
action: () => emit('play'),
},
{
id: 'open-instance',
shown: !!instancePath,
@@ -430,6 +427,10 @@ const messages = defineMessages({
<PlayIcon aria-hidden="true" />
{{ formatMessage(messages.playInstance) }}
</template>
<template #play-anyway>
<PlayIcon aria-hidden="true" />
{{ formatMessage(messages.playAnyway) }}
</template>
<template #open-instance>
<EyeIcon aria-hidden="true" />
{{ formatMessage(messages.viewInstance) }}

View File

@@ -311,24 +311,15 @@ export async function refreshWorlds(instancePath: string): Promise<World[]> {
return worlds ?? []
}
export function hasServerQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
if (!gameVersions.length) {
return true
}
const FIRST_QUICK_PLAY_VERSION = '23w14a'
const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion)
const targetIndex = gameVersions.findIndex((v) => v.version === 'a1.0.5_01')
return versionIndex === -1 || targetIndex === -1 || versionIndex <= targetIndex
}
export function hasWorldQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
export function hasQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
if (!gameVersions.length) {
return false
}
const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion)
const targetIndex = gameVersions.findIndex((v) => v.version === '23w14a')
const targetIndex = gameVersions.findIndex((v) => v.version === FIRST_QUICK_PLAY_VERSION)
return versionIndex !== -1 && targetIndex !== -1 && versionIndex <= targetIndex
}

View File

@@ -383,11 +383,11 @@
"instance.worlds.no_contact": {
"message": "Server couldn't be contacted"
},
"instance.worlds.no_server_quick_play": {
"message": "You can only jump straight into servers on Minecraft Alpha 1.0.5+"
"instance.worlds.no_quick_play": {
"message": "You can only jump straight into worlds on Minecraft 1.20+"
},
"instance.worlds.no_singleplayer_quick_play": {
"message": "You can only jump straight into singleplayer worlds on Minecraft 1.20+"
"instance.worlds.play_anyway": {
"message": "Play anyway"
},
"instance.worlds.play_instance": {
"message": "Play instance"

View File

@@ -67,8 +67,7 @@
:key="`world-${world.type}-${world.type == 'singleplayer' ? world.path : `${world.address}-${world.index}`}`"
:world="world"
:highlighted="highlightedWorld === getWorldIdentifier(world)"
:supports-server-quick-play="supportsServerQuickPlay"
:supports-world-quick-play="supportsWorldQuickPlay"
:supports-quick-play="supportsQuickPlay"
:current-protocol="protocolVersion"
:playing-instance="playing"
:playing-world="worldsMatch(world, worldPlaying)"
@@ -151,11 +150,10 @@ import {
refreshWorld,
sortWorlds,
refreshServers,
hasWorldQuickPlaySupport,
hasQuickPlaySupport,
refreshWorlds,
handleDefaultProfileUpdateEvent,
showWorldInFolder,
hasServerQuickPlaySupport,
} from '@/helpers/worlds.ts'
import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue'
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
@@ -357,11 +355,8 @@ function worldsMatch(world: World, other: World | undefined) {
}
const gameVersions = ref<GameVersion[]>(await get_game_versions().catch(() => []))
const supportsServerQuickPlay = computed(() =>
hasServerQuickPlaySupport(gameVersions.value, instance.value.game_version),
)
const supportsWorldQuickPlay = computed(() =>
hasWorldQuickPlaySupport(gameVersions.value, instance.value.game_version),
const supportsQuickPlay = computed(() =>
hasQuickPlaySupport(gameVersions.value, instance.value.game_version),
)
const filterOptions = computed(() => {

View File

@@ -29,8 +29,8 @@ export const useInstall = defineStore('installStore', {
setIncompatibilityWarningModal(ref) {
this.incompatibilityWarningModal = ref
},
showIncompatibilityWarningModal(instance, project, versions, selected, onInstall) {
this.incompatibilityWarningModal.show(instance, project, versions, selected, onInstall)
showIncompatibilityWarningModal(instance, project, versions, onInstall) {
this.incompatibilityWarningModal.show(instance, project, versions, onInstall)
},
setModInstallModal(ref) {
this.modInstallModal = ref
@@ -133,13 +133,7 @@ export const install = async (
callback(version.id)
} else {
const install = useInstall()
install.showIncompatibilityWarningModal(
instance,
project,
projectVersions,
version,
callback,
)
install.showIncompatibilityWarningModal(instance, project, projectVersions, callback)
}
} else {
const versions = (await get_version_many(project.versions).catch(handleError)).sort(

View File

@@ -250,7 +250,7 @@ pub async fn profile_get_pack_export_candidates(
// invoke('plugin:profile|profile_run', path)
#[tauri::command]
pub async fn profile_run(path: &str) -> Result<ProcessMetadata> {
let process = profile::run(path, QuickPlayType::None).await?;
let process = profile::run(path, &QuickPlayType::None).await?;
Ok(process)
}

View File

@@ -4,7 +4,6 @@ use enumset::EnumSet;
use tauri::{AppHandle, Manager, Runtime};
use theseus::prelude::ProcessMetadata;
use theseus::profile::{QuickPlayType, get_full_path};
use theseus::server_address::ServerAddress;
use theseus::worlds::{
DisplayStatus, ProtocolVersion, ServerPackStatus, ServerStatus, World,
WorldType, WorldWithProfile,
@@ -204,7 +203,7 @@ pub async fn start_join_singleplayer_world(
world: String,
) -> Result<ProcessMetadata> {
let process =
profile::run(path, QuickPlayType::Singleplayer(world)).await?;
profile::run(path, &QuickPlayType::Singleplayer(world)).await?;
Ok(process)
}
@@ -214,11 +213,8 @@ pub async fn start_join_server(
path: &str,
address: &str,
) -> Result<ProcessMetadata> {
let process = profile::run(
path,
QuickPlayType::Server(ServerAddress::Unresolved(address.to_owned())),
)
.await?;
let process =
profile::run(path, &QuickPlayType::Server(address.to_owned())).await?;
Ok(process)
}

View File

@@ -1,481 +1,510 @@
<template>
<div v-if="showInvitation" class="universal-card information invited my-4">
<h2>{{ getFormattedMessage(messages.invitationTitle) }}</h2>
<p v-if="currentMember?.project_role">
{{ formatMessage(messages.invitationWithRole, { role: currentMember.project_role }) }}
<div v-if="showInvitation" class="universal-card information invited">
<h2>Invitation to join project</h2>
<p>
You've been invited be a member of this project with the role of '{{ currentMember.role }}'.
</p>
<p v-else>{{ getFormattedMessage(messages.invitationNoRole) }}</p>
<div class="input-group">
<ButtonStyled color="brand">
<button class="brand-button" @click="acceptInvite()">
<CheckIcon />
{{ getFormattedMessage(messages.accept) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="declineInvite">
<XIcon />
{{ getFormattedMessage(messages.decline) }}
</button>
</ButtonStyled>
<button class="iconified-button brand-button" @click="acceptInvite()">
<CheckIcon />
Accept
</button>
<button class="iconified-button danger-button" @click="declineInvite()">
<XIcon />
Decline
</button>
</div>
</div>
<div
v-if="
currentMember &&
visibleNags.length > 0 &&
nags.filter((x) => x.condition).length > 0 &&
(project.status === 'draft' || tags.rejectedStatuses.includes(project.status))
"
class="universal-card my-4"
class="author-actions universal-card mb-4"
>
<div class="flex max-w-full flex-wrap items-center gap-x-6 gap-y-4">
<div class="flex flex-auto flex-wrap items-center gap-x-6 gap-y-4">
<h2 class="my-0 mr-auto">{{ getFormattedMessage(messages.publishingChecklist) }}</h2>
<div class="flex flex-row gap-2">
<div class="flex items-center gap-1">
<AsteriskIcon class="size-4 text-red" />
<span class="text-secondary">{{ getFormattedMessage(messages.required) }}</span>
</div>
|
<div class="flex items-center gap-1">
<TriangleAlertIcon class="size-4 text-orange" />
<span class="text-secondary">{{ getFormattedMessage(messages.warning) }}</span>
</div>
|
<div class="flex items-center gap-1">
<LightBulbIcon class="size-4 text-purple" />
<span class="text-secondary">{{ getFormattedMessage(messages.suggestion) }}</span>
<div class="header__row">
<div class="header__title">
<h2>Publishing checklist</h2>
<div class="checklist">
<span class="checklist__title">Progress:</span>
<div class="checklist__items">
<div
v-for="nag in nags"
:key="`checklist-${nag.id}`"
v-tooltip="nag.title"
:aria-label="nag.title"
:class="'circle ' + (!nag.condition ? 'done' : '') + nag.status"
class="circle"
>
<CheckIcon v-if="!nag.condition" />
<AsteriskIcon v-else-if="nag.status === 'required'" />
<LightBulbIcon v-else-if="nag.status === 'suggestion'" />
<ScaleIcon v-else-if="nag.status === 'review'" />
</div>
</div>
</div>
</div>
<div class="input-group">
<ButtonStyled circular>
<button :class="!collapsed && '[&>svg]:rotate-180'" @click="toggleCollapsed()">
<DropdownIcon class="duration-250 transition-transform ease-in-out" />
</button>
</ButtonStyled>
<button
:class="{ 'not-collapsed': !collapsed }"
class="square-button"
@click="toggleCollapsed()"
>
<DropdownIcon />
</button>
</div>
</div>
<div v-if="!collapsed" class="grid-display width-16 mt-4">
<div v-for="nag in visibleNags" :key="nag.id" class="grid-display__item">
<span class="flex items-center gap-2 font-semibold">
<component
:is="nag.icon || getDefaultIcon(nag.status)"
v-tooltip="getStatusTooltip(nag.status)"
:class="[
'size-4',
nag.status === 'required' && 'text-red',
nag.status === 'warning' && 'text-orange',
nag.status === 'suggestion' && 'text-purple',
]"
:aria-label="getStatusTooltip(nag.status)"
<div v-if="!collapsed" class="grid-display width-16">
<div
v-for="nag in nags.filter((x) => x.condition && !x.hide)"
:key="nag.id"
class="grid-display__item"
>
<span class="label">
<AsteriskIcon
v-if="nag.status === 'required'"
v-tooltip="'Required'"
:class="nag.status"
aria-label="Required"
/>
{{ getFormattedMessage(nag.title) }}
</span>
{{ getNagDescription(nag) }}
<LightBulbIcon
v-else-if="nag.status === 'suggestion'"
v-tooltip="'Suggestion'"
:class="nag.status"
aria-label="Suggestion"
/>
<ScaleIcon
v-else-if="nag.status === 'review'"
v-tooltip="'Review'"
:class="nag.status"
aria-label="Review"
/>{{ nag.title }}</span
>
{{ nag.description }}
<NuxtLink
v-if="nag.link && shouldShowLink(nag)"
v-if="nag.link"
:class="{ invisible: nag.link.hide }"
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/${
nag.link.path
}`"
class="goto-link"
>
{{ getFormattedMessage(nag.link.title) }}
{{ nag.link.title }}
<ChevronRightIcon aria-hidden="true" class="featured-header-chevron" />
</NuxtLink>
<ButtonStyled
v-if="nag.status === 'special-submit-action' && nag.id === 'submit-for-review'"
color="orange"
@click="submitForReview"
<button
v-else-if="nag.action"
:disabled="nag.action.disabled()"
class="btn btn-orange"
@click="nag.action.onClick"
>
<button
:disabled="!canSubmitForReview"
v-tooltip="
!canSubmitForReview ? getFormattedMessage(messages.submitChecklistTooltip) : undefined
"
>
<SendIcon />
{{ getFormattedMessage(messages.submitForReview) }}
</button>
</ButtonStyled>
<SendIcon />
{{ nag.action.title }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
<script setup>
import {
ChevronRightIcon,
CheckIcon,
XIcon,
AsteriskIcon,
LightBulbIcon,
TriangleAlertIcon,
DropdownIcon,
SendIcon,
ScaleIcon,
InfoIcon,
DropdownIcon,
} from "@modrinth/assets";
import { formatProjectType } from "@modrinth/utils";
import { acceptTeamInvite, removeTeamMember } from "~/helpers/teams.js";
import { nags } from "@modrinth/moderation";
import { ButtonStyled } from "@modrinth/ui";
import { useVIntl, defineMessages, type MessageDescriptor } from "@vintl/vintl";
import type { Nag, NagContext, NagStatus } from "@modrinth/moderation";
import type { Project, User, Version } from "@modrinth/utils";
import type { Component } from "vue";
interface Tags {
rejectedStatuses: string[];
}
const props = defineProps({
project: {
type: Object,
required: true,
},
versions: {
type: Array,
default() {
return [];
},
},
currentMember: {
type: Object,
default: null,
},
allMembers: {
type: Object,
default: null,
},
isSettings: {
type: Boolean,
default: false,
},
collapsed: {
type: Boolean,
default: false,
},
routeName: {
type: String,
default: "",
},
auth: {
type: Object,
required: true,
},
tags: {
type: Object,
required: true,
},
setProcessing: {
type: Function,
default() {
return () => {
addNotification({
group: "main",
title: "An error occurred",
text: "setProcessing function not found",
type: "error",
});
};
},
},
toggleCollapsed: {
type: Function,
default() {
return () => {
addNotification({
group: "main",
title: "An error occurred",
text: "toggleCollapsed function not found",
type: "error",
});
};
},
},
updateMembers: {
type: Function,
default() {
return () => {
addNotification({
group: "main",
title: "An error occurred",
text: "updateMembers function not found",
type: "error",
});
};
},
},
});
interface Auth {
user: {
id: string;
};
}
const featuredGalleryImage = computed(() => props.project.gallery.find((img) => img.featured));
interface Member {
accepted?: boolean;
project_role?: string;
user?: Partial<User>;
}
interface Props {
project: Project;
versions?: Version[];
currentMember?: Member | null;
allMembers?: Member[] | null;
isSettings?: boolean;
collapsed?: boolean;
routeName?: string;
auth: Auth;
tags: Tags;
setProcessing?: (processing: boolean) => void;
toggleCollapsed?: () => void;
updateMembers?: () => void | Promise<void>;
}
const messages = defineMessages({
invitationTitle: {
id: "project-member-header.invitation-title",
defaultMessage: "Invitation to join project",
const nags = computed(() => [
{
condition: props.versions.length < 1,
title: "Upload a version",
id: "upload-version",
description: "At least one version is required for a project to be submitted for review.",
status: "required",
link: {
path: "versions",
title: "Visit versions page",
hide: props.routeName === "type-id-versions",
},
},
invitationWithRole: {
id: "project-member-header.invitation-with-role",
defaultMessage: "You've been invited be a member of this project with the role of '{role}'.",
{
condition:
props.project.body === "" || props.project.body.startsWith("# Placeholder description"),
title: "Add a description",
id: "add-description",
description:
"A description that clearly describes the project's purpose and function is required.",
status: "required",
link: {
path: "settings/description",
title: "Visit description settings",
hide: props.routeName === "type-id-settings-description",
},
},
invitationNoRole: {
id: "project-member-header.invitation-no-role",
defaultMessage:
"You've been invited to join this project. Please accept or decline the invitation.",
{
condition: !props.project.icon_url,
title: "Add an icon",
id: "add-icon",
description:
"Your project should have a nice-looking icon to uniquely identify your project at a glance.",
status: "suggestion",
link: {
path: "settings",
title: "Visit general settings",
hide: props.routeName === "type-id-settings",
},
},
accept: {
id: "project-member-header.accept",
defaultMessage: "Accept",
{
condition: props.project.gallery.length === 0 || !featuredGalleryImage,
title: "Feature a gallery image",
id: "feature-gallery-image",
description: "Featured gallery images may be the first impression of many users.",
status: "suggestion",
link: {
path: "gallery",
title: "Visit gallery page",
hide: props.routeName === "type-id-gallery",
},
},
decline: {
id: "project-member-header.decline",
defaultMessage: "Decline",
{
hide: props.project.versions.length === 0,
condition: props.project.categories.length < 1,
title: "Select tags",
id: "select-tags",
description: "Select all tags that apply to your project.",
status: "suggestion",
link: {
path: "settings/tags",
title: "Visit tag settings",
hide: props.routeName === "type-id-settings-tags",
},
},
publishingChecklist: {
id: "project-member-header.publishing-checklist",
defaultMessage: "Publishing checklist",
{
condition: !(
props.project.issues_url ||
props.project.source_url ||
props.project.wiki_url ||
props.project.discord_url ||
props.project.donation_urls.length > 0
),
title: "Add external links",
id: "add-links",
description:
"Add any relevant links targeted outside of Modrinth, such as sources, issues, or a Discord invite.",
status: "suggestion",
link: {
path: "settings/links",
title: "Visit links settings",
hide: props.routeName === "type-id-settings-links",
},
},
submitForReview: {
id: "project-member-header.submit-for-review",
defaultMessage: "Submit for review",
{
hide:
props.project.versions.length === 0 ||
props.project.project_type === "resourcepack" ||
props.project.project_type === "plugin" ||
props.project.project_type === "shader" ||
props.project.project_type === "datapack",
condition:
props.project.client_side === "unknown" ||
props.project.server_side === "unknown" ||
(props.project.client_side === "unsupported" && props.project.server_side === "unsupported"),
title: "Select supported environments",
id: "select-environments",
description: `Select if the ${formatProjectType(
props.project.project_type,
).toLowerCase()} functions on the client-side and/or server-side.`,
status: "required",
link: {
path: "settings",
title: "Visit general settings",
hide: props.routeName === "type-id-settings",
},
},
submitForReviewDesc: {
id: "project-member-header.submit-for-review-desc",
defaultMessage:
{
condition: props.project.license.id === "LicenseRef-Unknown",
title: "Select license",
id: "select-license",
description: `Select the license your ${formatProjectType(
props.project.project_type,
).toLowerCase()} is distributed under.`,
status: "required",
link: {
path: "settings/license",
title: "Visit license settings",
hide: props.routeName === "type-id-settings-license",
},
},
{
condition: props.project.status === "draft",
title: "Submit for review",
id: "submit-for-review",
description:
"Your project is only viewable by members of the project. It must be reviewed by moderators in order to be published.",
status: "review",
link: null,
action: {
onClick: submitForReview,
title: "Submit for review",
disabled: () => nags.value.filter((x) => x.condition && x.status === "required").length > 0,
},
},
resubmitForReview: {
id: "project-member-header.resubmit-for-review",
defaultMessage: "Resubmit for review",
{
hide: props.project.stats === "draft",
condition: props.tags.rejectedStatuses.includes(props.project.status),
title: "Resubmit for review",
id: "resubmit-for-review",
description: `Your project has been ${props.project.status} by
Modrinth's staff. In most cases, you can resubmit for review after
addressing the staff's message.`,
status: "review",
link: {
path: "moderation",
title: "Visit moderation page",
hide: props.routeName === "type-id-moderation",
},
},
resubmitForReviewDesc: {
id: "project-member-header.resubmit-for-review-desc",
defaultMessage:
"Your project has been {status} by Modrinth's staff. In most cases, you can resubmit for review after addressing the staff's message.",
},
showKey: {
id: "project-member-header.show-key",
defaultMessage: "Toggle key",
},
keyTitle: {
id: "project-member-header.key-title",
defaultMessage: "Status Key",
},
action: {
id: "project-member-header.action",
defaultMessage: "Action",
},
visitModerationPage: {
id: "project-member-header.visit-moderation-page",
defaultMessage: "Visit moderation page",
},
submitChecklistTooltip: {
id: "project-member-header.submit-checklist-tooltip",
defaultMessage: "You must complete the required steps in the publishing checklist!",
},
successJoin: {
id: "project-member-header.success-join",
defaultMessage: "You have joined the project team",
},
errorJoin: {
id: "project-member-header.error-join",
defaultMessage: "Failed to accept team invitation",
},
successDecline: {
id: "project-member-header.success-decline",
defaultMessage: "You have declined the team invitation",
},
errorDecline: {
id: "project-member-header.error-decline",
defaultMessage: "Failed to decline team invitation",
},
success: {
id: "project-member-header.success",
defaultMessage: "Success",
},
error: {
id: "project-member-header.error",
defaultMessage: "Error",
},
required: {
id: "project-member-header.required",
defaultMessage: "Required",
},
warning: {
id: "project-member-header.warning",
defaultMessage: "Warning",
},
suggestion: {
id: "project-member-header.suggestion",
defaultMessage: "Suggestion",
},
});
]);
const { formatMessage } = useVIntl();
function getNagDescription(nag: Nag): string {
if (typeof nag.description === "function") {
return nag.description(nagContext.value);
}
return formatMessage(nag.description);
}
function getFormattedMessage(message: string | MessageDescriptor): string {
if (typeof message === "string") {
return message;
}
return formatMessage(message);
}
const props = withDefaults(defineProps<Props>(), {
versions: () => [],
currentMember: null,
allMembers: null,
isSettings: false,
collapsed: false,
routeName: "",
});
const emit = defineEmits<{
toggleCollapsed: [];
updateMembers: [];
setProcessing: [processing: boolean];
}>();
const nagContext = computed<NagContext>(() => ({
project: props.project,
versions: props.versions,
currentMember: props.currentMember as User,
currentRoute: props.routeName,
tags: props.tags,
submitProject: submitForReview,
}));
const showKey = ref(false);
function toggleKey(): void {
showKey.value = !showKey.value;
}
const canSubmitForReview = computed(() => {
return (
applicableNags.value.filter((nag) => nag.status === "required" && !isNagComplete(nag))
.length === 0
);
});
async function submitForReview() {
if (canSubmitForReview) {
await setProcessing(true);
}
}
const applicableNags = computed<Nag[]>(() => {
return nags.filter((nag) => {
return nag.shouldShow(nagContext.value);
});
});
function isNagComplete(nag: Nag): boolean {
const context = nagContext.value;
return !nag.shouldShow(context);
}
const visibleNags = computed<Nag[]>(() => {
const finalNags = applicableNags.value.filter((nag) => !isNagComplete(nag));
if (props.project.status === "draft") {
finalNags.push({
id: "submit-for-review",
title: messages.submitForReview,
description: () => formatMessage(messages.submitForReviewDesc),
status: "special-submit-action",
shouldShow: (ctx) => ctx.project.status === "draft",
});
}
if (props.tags.rejectedStatuses.includes(props.project.status)) {
finalNags.push({
id: "resubmit-for-review",
title: messages.resubmitForReview,
description: (ctx) =>
formatMessage(messages.resubmitForReviewDesc, { status: ctx.project.status }),
status: "special-submit-action",
shouldShow: (ctx) => ctx.tags.rejectedStatuses.includes(ctx.project.status),
link: {
path: "moderation",
title: messages.visitModerationPage,
shouldShow: () => props.routeName !== "type-id-moderation",
},
});
}
finalNags.sort((a, b) => {
const statusOrder = { required: 0, warning: 1, suggestion: 2, "special-submit-action": 3 };
return statusOrder[a.status] - statusOrder[b.status];
});
return finalNags;
});
function shouldShowLink(nag: Nag): boolean {
return nag.link?.shouldShow ? nag.link.shouldShow(nagContext.value) : false;
}
function getDefaultIcon(status: NagStatus): Component {
switch (status) {
case "required":
return AsteriskIcon;
case "warning":
return TriangleAlertIcon;
case "suggestion":
return LightBulbIcon;
case "special-submit-action":
return ScaleIcon;
default:
return AsteriskIcon;
}
}
function getStatusTooltip(status: NagStatus): string {
switch (status) {
case "required":
return formatMessage(messages.required);
case "warning":
return formatMessage(messages.warning);
case "suggestion":
return formatMessage(messages.suggestion);
default:
return formatMessage(messages.required);
}
}
const showInvitation = computed<boolean>(() => {
const showInvitation = computed(() => {
if (props.allMembers && props.auth) {
const member = props.allMembers.find((x) => x?.user?.id === props.auth.user.id);
return !!member && !member.accepted;
const member = props.allMembers.find((x) => x.user.id === props.auth.user.id);
return member && !member.accepted;
}
return false;
});
function toggleCollapsed(): void {
if (props.toggleCollapsed) {
props.toggleCollapsed();
} else {
emit("toggleCollapsed");
}
}
const acceptInvite = () => {
acceptTeamInvite(props.project.team);
props.updateMembers();
};
async function updateMembers(): Promise<void> {
if (props.updateMembers) {
await props.updateMembers();
} else {
emit("updateMembers");
}
}
const declineInvite = () => {
removeTeamMember(props.project.team, props.auth.user.id);
props.updateMembers();
};
function setProcessing(processing: boolean): void {
if (props.setProcessing) {
props.setProcessing(processing);
} else {
emit("setProcessing", processing);
const submitForReview = async () => {
if (
!props.acknowledgedMessage ||
nags.value.filter((x) => x.condition && x.status === "required").length === 0
) {
await props.setProcessing();
}
}
async function acceptInvite(): Promise<void> {
try {
setProcessing(true);
await acceptTeamInvite(props.project.team);
await updateMembers();
addNotification({
group: "main",
title: formatMessage(messages.success),
text: formatMessage(messages.successJoin),
type: "success",
});
} catch (error) {
addNotification({
group: "main",
title: formatMessage(messages.error),
text: formatMessage(messages.errorJoin),
type: "error",
});
} finally {
setProcessing(false);
}
}
async function declineInvite(): Promise<void> {
try {
setProcessing(true);
await removeTeamMember(props.project.team, props.auth.user.id);
await updateMembers();
addNotification({
group: "main",
title: formatMessage(messages.success),
text: formatMessage(messages.successDecline),
type: "success",
});
} catch (error) {
addNotification({
group: "main",
title: formatMessage(messages.error),
text: formatMessage(messages.errorDecline),
type: "error",
});
} finally {
setProcessing(false);
}
}
};
</script>
<style lang="scss" scoped>
.duration-250 {
transition-duration: 250ms;
.invited {
}
.author-actions {
margin-top: var(--spacing-card-md);
&:empty {
display: none;
}
.invisible {
visibility: hidden;
}
.header__row {
align-items: center;
column-gap: var(--spacing-card-lg);
row-gap: var(--spacing-card-md);
max-width: 100%;
.header__title {
display: flex;
flex-wrap: wrap;
align-items: center;
column-gap: var(--spacing-card-lg);
row-gap: var(--spacing-card-md);
flex-basis: min-content;
h2 {
margin: 0 auto 0 0;
}
}
button {
svg {
transition: transform 0.25s ease-in-out;
}
&.not-collapsed svg {
transform: rotate(180deg);
}
}
}
.grid-display__item .label {
display: flex;
gap: var(--spacing-card-xs);
align-items: center;
.required {
color: var(--color-red);
}
.suggestion {
color: var(--color-purple);
}
.review {
color: var(--color-orange);
}
}
.checklist {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--spacing-card-xs);
width: fit-content;
flex-wrap: wrap;
max-width: 100%;
.checklist__title {
font-weight: bold;
margin-right: var(--spacing-card-xs);
color: var(--color-text-dark);
}
.checklist__items {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--spacing-card-xs);
width: fit-content;
max-width: 100%;
}
.circle {
--circle-size: 2rem;
--background-color: var(--color-bg);
--content-color: var(--color-gray);
width: var(--circle-size);
height: var(--circle-size);
border-radius: 50%;
background-color: var(--background-color);
display: flex;
justify-content: center;
align-items: center;
svg {
color: var(--content-color);
width: calc(var(--circle-size) / 2);
height: calc(var(--circle-size) / 2);
}
&.required {
--content-color: var(--color-red);
}
&.suggestion {
--content-color: var(--color-purple);
}
&.review {
--content-color: var(--color-orange);
}
&.done {
--background-color: var(--color-green);
--content-color: var(--color-brand-inverted);
}
}
}
}
</style>

View File

@@ -259,7 +259,7 @@
</button>
</ButtonStyled>
<ButtonStyled color="green">
<button @click="sendMessage(project.requested_status ?? 'approved')">
<button @click="sendMessage('approved')">
<CheckIcon aria-hidden="true" />
Approve
</button>
@@ -355,7 +355,6 @@ import {
renderHighlightedString,
type ModerationJudgements,
type ModerationModpackItem,
type ProjectStatus,
} from "@modrinth/utils";
import { computedAsync, useLocalStorage } from "@vueuse/core";
import {
@@ -528,7 +527,7 @@ function handleKeybinds(event: KeyboardEvent) {
tryResetProgress: resetProgress,
tryExitModeration: () => emit("exit"),
tryApprove: () => sendMessage(props.project.requested_status),
tryApprove: () => sendMessage("approved"),
tryReject: () => sendMessage("rejected"),
tryWithhold: () => sendMessage("withheld"),
tryEditMessage: goBackToStages,
@@ -1209,7 +1208,7 @@ function generateModpackMessage(allFiles: {
}
const hasNextProject = ref(false);
async function sendMessage(status: ProjectStatus) {
async function sendMessage(status: "approved" | "rejected" | "withheld") {
try {
await useBaseFetch(`project/${props.project.id}`, {
method: "PATCH",

View File

@@ -2,10 +2,7 @@
<div class="static w-full grid-cols-1 md:relative md:flex">
<div class="static h-full flex-col pb-4 md:flex md:pb-0 md:pr-4">
<div class="z-10 flex select-none flex-col gap-2 rounded-2xl bg-bg-raised p-4 md:w-[16rem]">
<div
v-for="link in navLinks.filter((x) => x.shown === undefined || x.shown)"
:key="link.label"
>
<div v-for="link in navLinks" :key="link.label">
<NuxtLink
:to="link.href"
class="flex items-center gap-2 rounded-xl p-2 hover:bg-button-bg"
@@ -43,7 +40,7 @@ import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
const emit = defineEmits(["reinstall"]);
defineProps<{
navLinks: { label: string; href: string; icon: Component; external?: boolean; shown?: boolean }[];
navLinks: { label: string; href: string; icon: Component; external?: boolean }[];
route: RouteLocationNormalized;
server: ModrinthServer;
backupInProgress?: BackupInProgressReason;

View File

@@ -6,7 +6,6 @@ import { ServerModule } from "./base.ts";
export class GeneralModule extends ServerModule implements ServerGeneral {
server_id!: string;
name!: string;
owner_id!: string;
net!: { ip: string; port: number; domain: string };
game!: string;
backup_quota!: number;

View File

@@ -147,7 +147,7 @@ export async function useServersFetch<T>(
404: "Not Found",
405: "Method Not Allowed",
408: "Request Timeout",
429: "You're making requests too quickly. Please wait a moment and try again.",
429: "Too Many Requests",
500: "Internal Server Error",
502: "Bad Gateway",
503: "Service Unavailable",
@@ -167,17 +167,11 @@ export async function useServersFetch<T>(
console.error("Fetch error:", error);
const fetchError = new ModrinthServersFetchError(
`[Modrinth Servers] ${error.message}`,
`[Modrinth Servers] ${message}`,
statusCode,
error,
);
throw new ModrinthServerError(
`[Modrinth Servers] ${message}`,
statusCode,
fetchError,
module,
v1Error,
);
throw new ModrinthServerError(error.message, statusCode, fetchError, module, v1Error);
}
const baseDelay = statusCode && statusCode >= 500 ? 5000 : 1000;

View File

@@ -554,78 +554,6 @@
"profile.user-id": {
"message": "User ID: {id}"
},
"project-member-header.accept": {
"message": "Accept"
},
"project-member-header.action": {
"message": "Action"
},
"project-member-header.decline": {
"message": "Decline"
},
"project-member-header.error": {
"message": "Error"
},
"project-member-header.error-decline": {
"message": "Failed to decline team invitation"
},
"project-member-header.error-join": {
"message": "Failed to accept team invitation"
},
"project-member-header.invitation-no-role": {
"message": "You've been invited to join this project. Please accept or decline the invitation."
},
"project-member-header.invitation-title": {
"message": "Invitation to join project"
},
"project-member-header.invitation-with-role": {
"message": "You've been invited be a member of this project with the role of '{role}'."
},
"project-member-header.key-title": {
"message": "Status Key"
},
"project-member-header.publishing-checklist": {
"message": "Publishing checklist"
},
"project-member-header.required": {
"message": "Required"
},
"project-member-header.resubmit-for-review": {
"message": "Resubmit for review"
},
"project-member-header.resubmit-for-review-desc": {
"message": "Your project has been {status} by Modrinth's staff. In most cases, you can resubmit for review after addressing the staff's message."
},
"project-member-header.show-key": {
"message": "Toggle key"
},
"project-member-header.submit-checklist-tooltip": {
"message": "You must complete the required steps in the publishing checklist!"
},
"project-member-header.submit-for-review": {
"message": "Submit for review"
},
"project-member-header.submit-for-review-desc": {
"message": "Your project is only viewable by members of the project. It must be reviewed by moderators in order to be published."
},
"project-member-header.success": {
"message": "Success"
},
"project-member-header.success-decline": {
"message": "You have declined the team invitation"
},
"project-member-header.success-join": {
"message": "You have joined the project team"
},
"project-member-header.suggestion": {
"message": "Suggestion"
},
"project-member-header.visit-moderation-page": {
"message": "Visit moderation page"
},
"project-member-header.warning": {
"message": "Warning"
},
"project-type.collection.plural": {
"message": "Collections"
},

View File

@@ -22,10 +22,6 @@
"
: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">
<button
:disabled="!hasChanges"
@@ -42,8 +38,7 @@
</template>
<script lang="ts" setup>
import { SaveIcon, TriangleAlertIcon } from "@modrinth/assets";
import { countText, MIN_DESCRIPTION_CHARS } from "@modrinth/moderation";
import { SaveIcon } from "@modrinth/assets";
import { MarkdownEditor } from "@modrinth/ui";
import { type Project, type TeamMember, TeamMemberPermission } from "@modrinth/utils";
import { computed, ref } from "vue";
@@ -58,17 +53,6 @@ const props = defineProps<{
const description = ref(props.project.body);
const descriptionWarning = computed(() => {
const text = description.value?.trim() || "";
const charCount = countText(text);
if (charCount < MIN_DESCRIPTION_CHARS) {
return `It's recommended to have a description with at least ${MIN_DESCRIPTION_CHARS} readable characters. (${charCount}/${MIN_DESCRIPTION_CHARS})`;
}
return null;
});
const patchRequestPayload = computed(() => {
const payload: {
body?: string;

View File

@@ -82,10 +82,6 @@
<label for="project-summary">
<span class="label__title">Summary</span>
</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">
<textarea
id="project-summary"
@@ -244,18 +240,9 @@
<script setup>
import { formatProjectStatus, formatProjectType } from "@modrinth/utils";
import {
UploadIcon,
SaveIcon,
TrashIcon,
XIcon,
IssuesIcon,
CheckIcon,
TriangleAlertIcon,
} from "@modrinth/assets";
import { UploadIcon, SaveIcon, TrashIcon, XIcon, IssuesIcon, CheckIcon } from "@modrinth/assets";
import { Multiselect } from "vue-multiselect";
import { ConfirmModal, Avatar } from "@modrinth/ui";
import { MIN_SUMMARY_CHARS } from "@modrinth/moderation";
import FileInput from "~/components/ui/FileInput.vue";
const props = defineProps({
@@ -313,17 +300,6 @@ const hasDeletePermission = computed(() => {
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 patchData = computed(() => {

View File

@@ -7,26 +7,11 @@
id="project-issue-tracker"
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">
A place for users to report bugs, issues, and concerns about your project.
</span>
</label>
<TriangleAlertIcon
v-if="isIssuesLinkShortener"
v-tooltip="`Use of link shorteners is prohibited.`"
class="size-6 animate-pulse text-orange"
/>
<TriangleAlertIcon
v-else-if="isIssuesDiscordUrl"
v-tooltip="`Discord invites are not appropriate for this link type.`"
class="size-6 animate-pulse text-orange"
/>
<TriangleAlertIcon
v-else-if="!isIssuesUrlCommon"
v-tooltip="`Link includes a domain which isn't common for this link type.`"
class="size-6 animate-pulse text-orange"
/>
<input
id="project-issue-tracker"
v-model="issuesUrl"
@@ -41,26 +26,11 @@
id="project-source-code"
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">
A page/repository containing the source code for your project
</span>
</label>
<TriangleAlertIcon
v-if="isSourceLinkShortener"
v-tooltip="`Use of link shorteners is prohibited.`"
class="size-6 animate-pulse text-orange"
/>
<TriangleAlertIcon
v-else-if="isSourceDiscordUrl"
v-tooltip="`Discord invites are not appropriate for this link type.`"
class="size-6 animate-pulse text-orange"
/>
<TriangleAlertIcon
v-else-if="!isSourceUrlCommon"
v-tooltip="`Link includes a domain which isn't common for this link type.`"
class="size-6 animate-pulse text-orange"
/>
<input
id="project-source-code"
v-model="sourceUrl"
@@ -80,16 +50,6 @@
A page containing information, documentation, and help for the project.
</span>
</label>
<TriangleAlertIcon
v-if="isWikiLinkShortener"
v-tooltip="`Use of link shorteners is prohibited.`"
class="size-6 animate-pulse text-orange"
/>
<TriangleAlertIcon
v-else-if="isWikiDiscordUrl"
v-tooltip="`Discord invites are not appropriate for this link type.`"
class="size-6 animate-pulse text-orange"
/>
<input
id="project-wiki-page"
v-model="wikiUrl"
@@ -101,19 +61,9 @@
</div>
<div class="adjacent-input">
<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>
</label>
<TriangleAlertIcon
v-if="isDiscordLinkShortener"
v-tooltip="`Use of link shorteners is prohibited.`"
class="size-6 animate-pulse text-orange"
/>
<TriangleAlertIcon
v-else-if="!isDiscordUrlCommon"
v-tooltip="`You're using a link which isn't common for this link type.`"
class="size-6 animate-pulse text-orange"
/>
<input
id="project-discord-invite"
v-model="discordUrl"
@@ -173,13 +123,7 @@
<script setup>
import { DropdownSelect } from "@modrinth/ui";
import { SaveIcon, TriangleAlertIcon } from "@modrinth/assets";
import {
isCommonUrl,
isDiscordUrl,
isLinkShortener,
commonLinkDomains,
} from "@modrinth/moderation";
import { SaveIcon } from "@modrinth/assets";
const tags = useTags();
@@ -209,46 +153,6 @@ const sourceUrl = ref(props.project.source_url);
const wikiUrl = ref(props.project.wiki_url);
const discordUrl = ref(props.project.discord_url);
const isIssuesUrlCommon = computed(() => {
if (!issuesUrl.value || issuesUrl.value.trim().length === 0) return true;
return isCommonUrl(issuesUrl.value, commonLinkDomains.issues);
});
const isSourceUrlCommon = computed(() => {
if (!sourceUrl.value || sourceUrl.value.trim().length === 0) return true;
return isCommonUrl(sourceUrl.value, commonLinkDomains.source);
});
const isDiscordUrlCommon = computed(() => {
if (!discordUrl.value || discordUrl.value.trim().length === 0) return true;
return isCommonUrl(discordUrl.value, commonLinkDomains.discord);
});
const isIssuesDiscordUrl = computed(() => {
return isDiscordUrl(issuesUrl.value);
});
const isSourceDiscordUrl = computed(() => {
return isDiscordUrl(sourceUrl.value);
});
const isWikiDiscordUrl = computed(() => {
return isDiscordUrl(wikiUrl.value);
});
const isIssuesLinkShortener = computed(() => {
return isLinkShortener(issuesUrl.value);
});
const isSourceLinkShortener = computed(() => {
return isLinkShortener(sourceUrl.value);
});
const isWikiLinkShortener = computed(() => {
return isLinkShortener(wikiUrl.value);
});
const isDiscordLinkShortener = computed(() => {
return isLinkShortener(discordUrl.value);
});
const rawDonationLinks = JSON.parse(JSON.stringify(props.project.donation_urls));
rawDonationLinks.push({
id: null,

View File

@@ -6,31 +6,11 @@
<span class="label__title size-card-header">Tags</span>
</h3>
</div>
<div
v-if="tooManyTagsWarning && !allTagsSelectedWarning"
class="my-2 flex items-center gap-1.5 text-orange"
>
<TriangleAlertIcon class="my-auto" />
{{ tooManyTagsWarning }}
</div>
<div v-if="multipleResolutionTagsWarning" class="my-2 flex items-center gap-1.5 text-orange">
<TriangleAlertIcon class="my-auto" />
{{ multipleResolutionTagsWarning }}
</div>
<div v-if="allTagsSelectedWarning" class="my-2 flex items-center gap-1.5 text-red">
<TriangleAlertIcon class="my-auto" />
<span>{{ allTagsSelectedWarning }}</span>
</div>
<p>
Accurate tagging is important to help people find your
{{ formatProjectType(project.project_type).toLowerCase() }}. Make sure to select all tags
that apply.
</p>
<p v-if="project.versions.length === 0" class="known-errors">
Please upload a version first in order to select tags!
</p>
@@ -132,188 +112,145 @@
</div>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { StarIcon, SaveIcon, TriangleAlertIcon } from "@modrinth/assets";
import {
formatCategory,
formatCategoryHeader,
formatProjectType,
sortedCategories,
type Project,
} from "@modrinth/utils";
<script>
import { StarIcon, SaveIcon } from "@modrinth/assets";
import { formatCategory, formatCategoryHeader, formatProjectType } from "@modrinth/utils";
import Checkbox from "~/components/ui/Checkbox.vue";
interface Category {
name: string;
header: string;
icon?: string;
project_type: string;
}
export default defineNuxtComponent({
components: {
Checkbox,
SaveIcon,
StarIcon,
},
props: {
project: {
type: Object,
default() {
return {};
},
},
allMembers: {
type: Array,
default() {
return [];
},
},
currentMember: {
type: Object,
default() {
return null;
},
},
patchProject: {
type: Function,
default() {
return () => {
this.$notify({
group: "main",
title: "An error occurred",
text: "Patch project function not found",
type: "error",
});
};
},
},
},
data() {
return {
selectedTags: this.$sortedCategories().filter(
(x) =>
x.project_type === this.project.actualProjectType &&
(this.project.categories.includes(x.name) ||
this.project.additional_categories.includes(x.name)),
),
featuredTags: this.$sortedCategories().filter(
(x) =>
x.project_type === this.project.actualProjectType &&
this.project.categories.includes(x.name),
),
};
},
computed: {
categoryLists() {
const lists = {};
this.$sortedCategories().forEach((x) => {
if (x.project_type === this.project.actualProjectType) {
const header = x.header;
if (!lists[header]) {
lists[header] = [];
}
lists[header].push(x);
}
});
return lists;
},
patchData() {
const data = {};
// Promote selected categories to featured if there are less than 3 featured
const newFeaturedTags = this.featuredTags.slice();
if (newFeaturedTags.length < 1 && this.selectedTags.length > newFeaturedTags.length) {
const nonFeaturedCategories = this.selectedTags.filter((x) => !newFeaturedTags.includes(x));
interface Props {
project: Project & {
actualProjectType: string;
};
allMembers?: any[];
currentMember?: any;
patchProject?: (data: any) => void;
}
nonFeaturedCategories
.slice(0, Math.min(nonFeaturedCategories.length, 3 - newFeaturedTags.length))
.forEach((x) => newFeaturedTags.push(x));
}
// Convert selected and featured categories to backend-usable arrays
const categories = newFeaturedTags.map((x) => x.name);
const additionalCategories = this.selectedTags
.filter((x) => !newFeaturedTags.includes(x))
.map((x) => x.name);
const tags = useTags();
if (
categories.length !== this.project.categories.length ||
categories.some((value) => !this.project.categories.includes(value))
) {
data.categories = categories;
}
const props = withDefaults(defineProps<Props>(), {
allMembers: () => [],
currentMember: null,
patchProject: () => {
addNotification({
title: "An error occurred",
text: "Patch project function not found",
type: "error",
});
if (
additionalCategories.length !== this.project.additional_categories.length ||
additionalCategories.some((value) => !this.project.additional_categories.includes(value))
) {
data.additional_categories = additionalCategories;
}
return data;
},
hasChanges() {
return Object.keys(this.patchData).length > 0;
},
},
methods: {
formatProjectType,
formatCategoryHeader,
formatCategory,
toggleCategory(category) {
if (this.selectedTags.includes(category)) {
this.selectedTags = this.selectedTags.filter((x) => x !== category);
if (this.featuredTags.includes(category)) {
this.featuredTags = this.featuredTags.filter((x) => x !== category);
}
} else {
this.selectedTags.push(category);
}
},
toggleFeaturedCategory(category) {
if (this.featuredTags.includes(category)) {
this.featuredTags = this.featuredTags.filter((x) => x !== category);
} else {
this.featuredTags.push(category);
}
},
saveChanges() {
if (this.hasChanges) {
this.patchProject(this.patchData);
}
},
},
});
const selectedTags = ref<Category[]>(
sortedCategories(tags.value).filter(
(x: Category) =>
x.project_type === props.project.actualProjectType &&
(props.project.categories.includes(x.name) ||
props.project.additional_categories.includes(x.name)),
),
);
const featuredTags = ref<Category[]>(
sortedCategories(tags.value).filter(
(x: Category) =>
x.project_type === props.project.actualProjectType &&
props.project.categories.includes(x.name),
),
);
const categoryLists = computed(() => {
const lists: Record<string, Category[]> = {};
sortedCategories(tags.value).forEach((x: Category) => {
if (x.project_type === props.project.actualProjectType) {
const header = x.header;
if (!lists[header]) {
lists[header] = [];
}
lists[header].push(x);
}
});
return lists;
});
const tooManyTagsWarning = computed(() => {
const tagCount = selectedTags.value.length;
if (tagCount > 8) {
return `You've selected ${tagCount} tags. Consider reducing to 8 or fewer to keep your project focused and easier to discover.`;
}
return null;
});
const multipleResolutionTagsWarning = computed(() => {
if (props.project.project_type !== "resourcepack") return null;
const resolutionTags = selectedTags.value.filter((tag) =>
["8x-", "16x", "32x", "48x", "64x", "128x", "256x", "512x+"].includes(tag.name),
);
if (resolutionTags.length > 1) {
return `You've selected ${resolutionTags.length} resolution tags (${resolutionTags
.map((t) => t.name)
.join(", ")
.replace("8x-", "8x or lower")
.replace(
"512x+",
"512x or higher",
)}). Resource packs should typically only have one resolution tag.`;
}
return null;
});
const allTagsSelectedWarning = computed(() => {
const categoriesForProjectType = sortedCategories(tags.value).filter(
(x: Category) => x.project_type === props.project.actualProjectType,
);
const totalSelectedTags = selectedTags.value.length;
if (
totalSelectedTags === categoriesForProjectType.length &&
categoriesForProjectType.length > 0
) {
return `You've selected all ${categoriesForProjectType.length} available tags. Please select only the tags that truly apply to your project.`;
}
return null;
});
const patchData = computed(() => {
const data: Record<string, string[]> = {};
// Promote selected categories to featured if there are less than 3 featured
const newFeaturedTags = featuredTags.value.slice();
if (newFeaturedTags.length < 1 && selectedTags.value.length > newFeaturedTags.length) {
const nonFeaturedCategories = selectedTags.value.filter((x) => !newFeaturedTags.includes(x));
nonFeaturedCategories
.slice(0, Math.min(nonFeaturedCategories.length, 3 - newFeaturedTags.length))
.forEach((x) => newFeaturedTags.push(x));
}
// Convert selected and featured categories to backend-usable arrays
const categories = newFeaturedTags.map((x) => x.name);
const additionalCategories = selectedTags.value
.filter((x) => !newFeaturedTags.includes(x))
.map((x) => x.name);
if (
categories.length !== props.project.categories.length ||
categories.some((value) => !props.project.categories.includes(value))
) {
data.categories = categories;
}
if (
additionalCategories.length !== props.project.additional_categories.length ||
additionalCategories.some((value) => !props.project.additional_categories.includes(value))
) {
data.additional_categories = additionalCategories;
}
return data;
});
const hasChanges = computed(() => {
return Object.keys(patchData.value).length > 0;
});
const toggleCategory = (category: Category) => {
if (selectedTags.value.includes(category)) {
selectedTags.value = selectedTags.value.filter((x) => x !== category);
if (featuredTags.value.includes(category)) {
featuredTags.value = featuredTags.value.filter((x) => x !== category);
}
} else {
selectedTags.value.push(category);
}
};
const toggleFeaturedCategory = (category: Category) => {
if (featuredTags.value.includes(category)) {
featuredTags.value = featuredTags.value.filter((x) => x !== category);
} else {
featuredTags.value.push(category);
}
};
const saveChanges = () => {
if (hasChanges.value) {
props.patchProject(patchData.value);
}
};
</script>
<style lang="scss" scoped>
.label__title {
display: flex;

View File

@@ -16,15 +16,12 @@ import {
CardIcon,
UserIcon,
WrenchIcon,
ModrinthIcon,
} from "@modrinth/assets";
import { isAdmin as isUserAdmin, type User } from "@modrinth/utils";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
const route = useRoute();
const serverId = route.params.id as string;
const auth = await useAuth();
const props = defineProps<{
server: ModrinthServer;
@@ -35,11 +32,7 @@ useHead({
title: `Options - ${props.server.general?.name ?? "Server"} - Modrinth`,
});
const ownerId = computed(() => props.server.general?.owner_id ?? "Ghost");
const isOwner = computed(() => (auth.value?.user as User | null)?.id === ownerId.value);
const isAdmin = computed(() => isUserAdmin(auth.value?.user));
const navLinks = computed(() => [
const navLinks = [
{ icon: SettingsIcon, label: "General", href: `/servers/manage/${serverId}/options` },
{ icon: WrenchIcon, label: "Platform", href: `/servers/manage/${serverId}/options/loader` },
{ icon: TextQuoteIcon, label: "Startup", href: `/servers/manage/${serverId}/options/startup` },
@@ -55,15 +48,7 @@ const navLinks = computed(() => [
label: "Billing",
href: `/settings/billing#server-${serverId}`,
external: true,
shown: isOwner.value,
},
{
icon: ModrinthIcon,
label: "Admin Billing",
href: `/admin/billing/${ownerId.value}`,
external: true,
shown: isAdmin.value,
},
{ icon: InfoIcon, label: "Info", href: `/servers/manage/${serverId}/options/info` },
]);
];
</script>

View File

@@ -42,7 +42,7 @@
</label>
<ButtonStyled>
<button
:disabled="invocation === originalInvocation"
:disabled="invocation === startupSettings?.original_invocation"
class="!w-full sm:!w-auto"
@click="resetToDefault"
>
@@ -120,9 +120,8 @@ const props = defineProps<{
server: ModrinthServer;
}>();
await props.server.startup.fetch();
const data = computed(() => props.server.general);
const startupSettings = computed(() => props.server.startup);
const showAllVersions = ref(false);
const jdkVersionMap = [
@@ -138,15 +137,33 @@ const jdkBuildMap = [
{ value: "graal", label: "GraalVM" },
];
const invocation = ref(props.server.startup.invocation);
const jdkVersion = ref(
jdkVersionMap.find((v) => v.value === props.server.startup.jdk_version)?.label,
);
const jdkBuild = ref(jdkBuildMap.find((v) => v.value === props.server.startup.jdk_build)?.label);
const invocation = ref("");
const jdkVersion = ref("");
const jdkBuild = ref("");
const originalInvocation = ref(invocation.value);
const originalJdkVersion = ref(jdkVersion.value);
const originalJdkBuild = ref(jdkBuild.value);
const originalInvocation = ref("");
const originalJdkVersion = ref("");
const originalJdkBuild = ref("");
watch(
startupSettings,
(newSettings) => {
if (newSettings) {
invocation.value = newSettings.invocation;
originalInvocation.value = newSettings.invocation;
const jdkVersionLabel =
jdkVersionMap.find((v) => v.value === newSettings.jdk_version)?.label || "";
jdkVersion.value = jdkVersionLabel;
originalJdkVersion.value = jdkVersionLabel;
const jdkBuildLabel = jdkBuildMap.find((v) => v.value === newSettings.jdk_build)?.label || "";
jdkBuild.value = jdkBuildLabel;
originalJdkBuild.value = jdkBuildLabel;
}
},
{ immediate: true },
);
const hasUnsavedChanges = computed(
() =>
@@ -178,7 +195,7 @@ const displayedJavaVersions = computed(() => {
return showAllVersions.value ? jdkVersionMap.map((v) => v.label) : compatibleJavaVersions.value;
});
async function saveStartup() {
const saveStartup = async () => {
try {
isUpdating.value = true;
const invocationValue = invocation.value ?? "";
@@ -215,17 +232,17 @@ async function saveStartup() {
} finally {
isUpdating.value = false;
}
}
};
function resetStartup() {
const resetStartup = () => {
invocation.value = originalInvocation.value;
jdkVersion.value = originalJdkVersion.value;
jdkBuild.value = originalJdkBuild.value;
}
};
function resetToDefault() {
invocation.value = originalInvocation.value ?? "";
}
const resetToDefault = () => {
invocation.value = startupSettings.value?.original_invocation ?? "";
};
</script>
<style scoped>

View File

@@ -16,7 +16,6 @@
"blog:fix": "turbo run fix --filter=@modrinth/blog",
"pages:build": "NITRO_PRESET=cloudflare-pages pnpm --filter frontend run build",
"moderation:fix": "turbo run fix --filter=@modrinth/moderation",
"moderation:intl:extract": "pnpm run --filter=@modrinth/moderation intl:extract",
"build": "turbo run build --continue",
"lint": "turbo run lint --continue",
"test": "turbo run test --continue",

View File

@@ -53,7 +53,6 @@ fn build_java_jars() {
.arg("build")
.arg("--no-daemon")
.arg("--console=rich")
.arg("--info")
.current_dir(dunce::canonicalize("java").unwrap())
.status()
.expect("Failed to wait on Gradle build");

View File

@@ -1,7 +1,6 @@
plugins {
java
id("com.diffplug.spotless") version "7.0.4"
id("com.gradleup.shadow") version "9.0.0-rc2"
}
repositories {
@@ -9,9 +8,6 @@ repositories {
}
dependencies {
implementation("org.ow2.asm:asm:9.8")
implementation("org.ow2.asm:asm-tree:9.8")
testImplementation(libs.junit.jupiter)
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
@@ -35,17 +31,7 @@ spotless {
}
tasks.jar {
enabled = false
}
tasks.shadowJar {
archiveFileName = "theseus.jar"
manifest {
attributes["Premain-Class"] = "com.modrinth.theseus.agent.TheseusAgent"
}
enableRelocation = true
relocationPrefix = "com.modrinth.theseus.shadow"
}
tasks.named<Test>("test") {

View File

@@ -1,45 +0,0 @@
package com.modrinth.theseus.agent;
import java.util.ListIterator;
import java.util.function.Predicate;
import org.objectweb.asm.Type;
import org.objectweb.asm.tree.AbstractInsnNode;
import org.objectweb.asm.tree.FieldInsnNode;
public interface InsnPattern extends Predicate<AbstractInsnNode> {
/**
* Advances past the first match of all instructions in the pattern.
* @return {@code true} if the pattern was found, {@code false} if not
*/
static boolean findAndSkip(ListIterator<AbstractInsnNode> iterator, InsnPattern... pattern) {
if (pattern.length == 0) {
return true;
}
int patternIndex = 0;
while (iterator.hasNext()) {
final AbstractInsnNode insn = iterator.next();
if (insn.getOpcode() == -1) continue;
if (pattern[patternIndex].test(insn) && ++patternIndex == pattern.length) {
return true;
} else {
patternIndex = 0;
}
}
return false;
}
static InsnPattern opcode(int opcode) {
return insn -> insn.getOpcode() == opcode;
}
static InsnPattern field(int opcode, Type fieldType) {
final String typeDescriptor = fieldType.getDescriptor();
return insn -> {
if (insn.getOpcode() != opcode || !(insn instanceof FieldInsnNode)) {
return false;
}
final FieldInsnNode fieldInsn = (FieldInsnNode) insn;
return typeDescriptor.equals(fieldInsn.desc);
};
}
}

View File

@@ -1,12 +0,0 @@
package com.modrinth.theseus.agent;
// Must be kept up-to-date with quick_play_version.rs
public enum QuickPlayServerVersion {
BUILTIN,
BUILTIN_LEGACY,
INJECTED,
UNSUPPORTED;
public static final QuickPlayServerVersion CURRENT =
valueOf(System.getProperty("modrinth.internal.quickPlay.serverVersion"));
}

View File

@@ -1,85 +0,0 @@
package com.modrinth.theseus.agent;
import com.modrinth.theseus.agent.transformers.ClassTransformer;
import com.modrinth.theseus.agent.transformers.MinecraftTransformer;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.lang.instrument.Instrumentation;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.HashMap;
import java.util.Map;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
@SuppressWarnings({"NullableProblems", "CallToPrintStackTrace"})
public final class TheseusAgent {
private static final boolean DEBUG_AGENT = Boolean.getBoolean("modrinth.debugAgent");
public static void premain(String args, Instrumentation instrumentation) {
final Path debugPath = Paths.get("ModrinthDebugTransformed");
if (DEBUG_AGENT) {
System.out.println(
"===== Theseus agent debugging enabled. Dumping transformed classes to " + debugPath + " =====");
if (Files.exists(debugPath)) {
try {
Files.walkFileTree(debugPath, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
Files.delete(file);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
Files.delete(dir);
return FileVisitResult.CONTINUE;
}
});
} catch (IOException e) {
new UncheckedIOException("Failed to delete " + debugPath, e).printStackTrace();
}
}
System.out.println("===== Quick play server version: " + QuickPlayServerVersion.CURRENT + " =====");
}
final Map<String, ClassTransformer> transformers = new HashMap<>();
transformers.put("net/minecraft/client/Minecraft", new MinecraftTransformer());
instrumentation.addTransformer((loader, className, classBeingRedefined, protectionDomain, classData) -> {
final ClassTransformer transformer = transformers.get(className);
if (transformer == null) {
return null;
}
final ClassReader reader = new ClassReader(classData);
final ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_MAXS);
try {
if (!transformer.transform(reader, writer)) {
if (DEBUG_AGENT) {
System.out.println("Not writing " + className + " as its transformer returned false");
}
return null;
}
} catch (Throwable t) {
new IllegalStateException("Failed to transform " + className, t).printStackTrace();
return null;
}
final byte[] result = writer.toByteArray();
if (DEBUG_AGENT) {
try {
final Path path = debugPath.resolve(className + ".class");
Files.createDirectories(path.getParent());
Files.write(path, result);
System.out.println("Dumped class to " + path.toAbsolutePath());
} catch (IOException e) {
new UncheckedIOException("Failed to dump class " + className, e).printStackTrace();
}
}
return result;
});
}
}

View File

@@ -1,20 +0,0 @@
package com.modrinth.theseus.agent.transformers;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.tree.ClassNode;
public abstract class ClassNodeTransformer extends ClassTransformer {
protected abstract boolean transform(ClassNode classNode);
@Override
public final boolean transform(ClassReader reader, ClassWriter writer) {
final ClassNode classNode = new ClassNode();
reader.accept(classNode, 0);
if (!transform(classNode)) {
return false;
}
classNode.accept(writer);
return true;
}
}

View File

@@ -1,14 +0,0 @@
package com.modrinth.theseus.agent.transformers;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.tree.ClassNode;
public abstract class ClassTransformer {
public abstract boolean transform(ClassReader reader, ClassWriter writer);
protected static boolean needsStackMap(ClassNode classNode) {
return (classNode.version & 0xffff) >= Opcodes.V1_6;
}
}

View File

@@ -1,99 +0,0 @@
package com.modrinth.theseus.agent.transformers;
import com.modrinth.theseus.agent.InsnPattern;
import com.modrinth.theseus.agent.QuickPlayServerVersion;
import java.util.ListIterator;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.tree.AbstractInsnNode;
import org.objectweb.asm.tree.ClassNode;
import org.objectweb.asm.tree.FrameNode;
import org.objectweb.asm.tree.InsnNode;
import org.objectweb.asm.tree.JumpInsnNode;
import org.objectweb.asm.tree.LabelNode;
import org.objectweb.asm.tree.LdcInsnNode;
import org.objectweb.asm.tree.MethodInsnNode;
import org.objectweb.asm.tree.MethodNode;
import org.objectweb.asm.tree.VarInsnNode;
public final class MinecraftTransformer extends ClassNodeTransformer {
private static final String SET_SERVER_NAME_DESC = "(Ljava/lang/String;I)V";
private static final InsnPattern[] INITIALIZE_THIS_PATTERN = {InsnPattern.opcode(Opcodes.INVOKESPECIAL)};
@Override
protected boolean transform(ClassNode classNode) {
if (QuickPlayServerVersion.CURRENT == QuickPlayServerVersion.INJECTED) {
return addServerJoinSupport(classNode);
}
return false;
}
private static boolean addServerJoinSupport(ClassNode classNode) {
String setServerName = null;
MethodNode constructor = null;
for (final MethodNode method : classNode.methods) {
if (constructor == null && method.name.equals("<init>")) {
constructor = method;
} else if (method.desc.equals(SET_SERVER_NAME_DESC) && method.name.indexOf('$') == -1) {
// Check for $ is because Mixin-injected methods should have $ in it
if (setServerName == null) {
setServerName = method.name;
} else {
// Already found a setServer method, but we found another one? Since we can't
// know which is real, just return so we don't call something we shouldn't.
// Note this can't happen unless some other mod is adding a method with this
// same descriptor.
return false;
}
}
}
if (constructor == null) {
return false;
}
final ListIterator<AbstractInsnNode> it = constructor.instructions.iterator();
if (!InsnPattern.findAndSkip(it, INITIALIZE_THIS_PATTERN)) {
return true;
}
final LabelNode noQuickPlayLabel = new LabelNode();
final LabelNode doneQuickPlayLabel = new LabelNode();
it.add(new LdcInsnNode("modrinth.internal.quickPlay.host"));
// String
it.add(new MethodInsnNode(
Opcodes.INVOKESTATIC, "java/lang/System", "getProperty", "(Ljava/lang/String;)Ljava/lang/String;"));
// String
it.add(new InsnNode(Opcodes.DUP));
// String String
it.add(new JumpInsnNode(Opcodes.IFNULL, noQuickPlayLabel));
// String
it.add(new VarInsnNode(Opcodes.ALOAD, 0));
// String Minecraft
it.add(new InsnNode(Opcodes.SWAP));
// Minecraft String
it.add(new LdcInsnNode("modrinth.internal.quickPlay.port"));
// Minecraft String String
it.add(new MethodInsnNode(
Opcodes.INVOKESTATIC, "java/lang/System", "getProperty", "(Ljava/lang/String;)Ljava/lang/String;"));
// Minecraft String String
it.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "java/lang/Integer", "parseInt", "(Ljava/lang/String;)I"));
// Minecraft String int
it.add(new MethodInsnNode(
Opcodes.INVOKEVIRTUAL, "net/minecraft/client/Minecraft", setServerName, SET_SERVER_NAME_DESC));
//
it.add(new JumpInsnNode(Opcodes.GOTO, doneQuickPlayLabel));
it.add(noQuickPlayLabel);
if (needsStackMap(classNode)) {
it.add(new FrameNode(Opcodes.F_SAME, 0, null, 0, null));
}
// String
it.add(new InsnNode(Opcodes.POP));
//
it.add(doneQuickPlayLabel);
if (needsStackMap(classNode)) {
it.add(new FrameNode(Opcodes.F_SAME, 0, null, 0, null));
}
//
return true;
}
}

View File

@@ -11,7 +11,6 @@ pub mod mr_auth;
pub mod pack;
pub mod process;
pub mod profile;
pub mod server_address;
pub mod settings;
pub mod tags;
pub mod worlds;

View File

@@ -23,7 +23,6 @@ use serde_json::json;
use std::collections::{HashMap, HashSet};
use crate::data::Settings;
use crate::server_address::ServerAddress;
use dashmap::DashMap;
use std::iter::FromIterator;
use std::{
@@ -41,7 +40,7 @@ pub mod update;
pub enum QuickPlayType {
None,
Singleplayer(String),
Server(ServerAddress),
Server(String),
}
/// Remove a profile
@@ -631,7 +630,7 @@ fn pack_get_relative_path(
#[tracing::instrument]
pub async fn run(
path: &str,
quick_play_type: QuickPlayType,
quick_play_type: &QuickPlayType,
) -> crate::Result<ProcessMetadata> {
let state = State::get().await?;
@@ -647,7 +646,7 @@ pub async fn run(
async fn run_credentials(
path: &str,
credentials: &Credentials,
quick_play_type: QuickPlayType,
quick_play_type: &QuickPlayType,
) -> crate::Result<ProcessMetadata> {
let state = State::get().await?;
let settings = Settings::get(&state.pool).await?;

View File

@@ -1,166 +0,0 @@
use crate::{Error, ErrorKind, Result};
use std::fmt::Display;
use std::mem;
use std::net::{Ipv4Addr, Ipv6Addr};
use tokio::sync::Semaphore;
#[derive(Debug, Clone)]
pub enum ServerAddress {
Unresolved(String),
Resolved {
original_host: String,
original_port: u16,
resolved_host: String,
resolved_port: u16,
},
}
impl ServerAddress {
pub async fn resolve(&mut self) -> Result<()> {
match self {
Self::Unresolved(address) => {
let (host, port) = parse_server_address(address)?;
let (resolved_host, resolved_port) =
resolve_server_address(host, port).await?;
*self = Self::Resolved {
original_host: if host.len() == address.len() {
mem::take(address)
} else {
host.to_owned()
},
original_port: port,
resolved_host,
resolved_port,
}
}
Self::Resolved { .. } => {}
}
Ok(())
}
pub fn require_resolved(&self) -> Result<(&str, u16)> {
match self {
Self::Resolved {
resolved_host,
resolved_port,
..
} => Ok((resolved_host, *resolved_port)),
Self::Unresolved(address) => Err(ErrorKind::InputError(format!(
"Unexpected unresolved server address: {address}"
))
.into()),
}
}
}
impl Display for ServerAddress {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unresolved(address) => write!(f, "{address}"),
Self::Resolved {
resolved_host,
resolved_port,
..
} => {
if resolved_host.contains(':') {
write!(f, "[{resolved_host}]:{resolved_port}")
} else {
write!(f, "{resolved_host}:{resolved_port}")
}
}
}
}
}
pub fn parse_server_address(address: &str) -> Result<(&str, u16)> {
parse_server_address_inner(address)
.map_err(|e| Error::from(ErrorKind::InputError(e)))
}
// Reimplementation of Guava's HostAndPort#fromString with a default port of 25565
fn parse_server_address_inner(
address: &str,
) -> std::result::Result<(&str, u16), String> {
let (host, port_str) = if address.starts_with("[") {
let colon_index = address.find(':');
let close_bracket_index = address.rfind(']');
if colon_index.is_none() || close_bracket_index.is_none() {
return Err(format!("Invalid bracketed host/port: {address}"));
}
let close_bracket_index = close_bracket_index.unwrap();
let host = &address[1..close_bracket_index];
if close_bracket_index + 1 == address.len() {
(host, "")
} else {
if address.as_bytes().get(close_bracket_index).copied()
!= Some(b':')
{
return Err(format!(
"Only a colon may follow a close bracket: {address}"
));
}
let port_str = &address[close_bracket_index + 2..];
for c in port_str.chars() {
if !c.is_ascii_digit() {
return Err(format!("Port must be numeric: {address}"));
}
}
(host, port_str)
}
} else {
let colon_pos = address.find(':');
if let Some(colon_pos) = colon_pos {
(&address[..colon_pos], &address[colon_pos + 1..])
} else {
(address, "")
}
};
let mut port = None;
if !port_str.is_empty() {
if port_str.starts_with('+') {
return Err(format!("Unparseable port number: {port_str}"));
}
port = port_str.parse::<u16>().ok();
if port.is_none() {
return Err(format!("Unparseable port number: {port_str}"));
}
}
Ok((host, port.unwrap_or(25565)))
}
pub async fn resolve_server_address(
host: &str,
port: u16,
) -> Result<(String, u16)> {
static SIMULTANEOUS_DNS_QUERIES: Semaphore = Semaphore::const_new(24);
if port != 25565
|| host.parse::<Ipv4Addr>().is_ok()
|| host.parse::<Ipv6Addr>().is_ok()
{
return Ok((host.to_owned(), port));
}
let _permit = SIMULTANEOUS_DNS_QUERIES.acquire().await?;
let resolver = hickory_resolver::TokioResolver::builder_tokio()?.build();
Ok(
match resolver.srv_lookup(format!("_minecraft._tcp.{host}")).await {
Err(e)
if e.proto()
.filter(|x| x.kind().is_no_records_found())
.is_some() =>
{
None
}
Err(e) => return Err(e.into()),
Ok(lookup) => lookup
.into_iter()
.next()
.map(|r| (r.target().to_string(), r.port())),
}
.unwrap_or_else(|| (host.to_owned(), port)),
)
}

View File

@@ -1,7 +1,6 @@
use crate::data::ModLoader;
use crate::launcher::get_loader_version_from_profile;
use crate::profile::get_full_path;
use crate::server_address::{parse_server_address, resolve_server_address};
use crate::state::attached_world_data::AttachedWorldData;
use crate::state::{
Profile, ProfileInstallStage, attached_world_data, server_join_log,
@@ -12,7 +11,7 @@ pub use crate::util::server_ping::{
ServerGameProfile, ServerPlayers, ServerStatus, ServerVersion,
};
use crate::util::{io, server_ping};
use crate::{ErrorKind, Result, State, launcher};
use crate::{Error, ErrorKind, Result, State, launcher};
use async_walkdir::WalkDir;
use async_zip::{Compression, ZipEntryBuilder};
use chrono::{DateTime, Local, TimeZone, Utc};
@@ -25,9 +24,11 @@ use regex::{Regex, RegexBuilder};
use serde::{Deserialize, Serialize};
use std::cmp::Reverse;
use std::io::Cursor;
use std::net::{Ipv4Addr, Ipv6Addr};
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use tokio::io::AsyncWriteExt;
use tokio::sync::Semaphore;
use tokio::task::JoinSet;
use tokio_util::compat::FuturesAsyncWriteCompatExt;
use url::Url;
@@ -432,9 +433,9 @@ async fn get_server_worlds_in_profile(
let mut futures = JoinSet::new();
for (index, world) in worlds.iter().enumerate().skip(first_server_index)
{
// We can't check for the profile already having a last_played, in case the user joined
// the target address directly more recently. This is often the case when using
// quick-play before 1.20.
if world.last_played.is_some() {
continue;
}
if let WorldDetails::Server { address, .. } = &world.details
&& let Ok((host, port)) = parse_server_address(address)
{
@@ -916,3 +917,93 @@ pub async fn get_server_status(
)
.await
}
pub fn parse_server_address(address: &str) -> Result<(&str, u16)> {
parse_server_address_inner(address)
.map_err(|e| Error::from(ErrorKind::InputError(e)))
}
// Reimplementation of Guava's HostAndPort#fromString with a default port of 25565
fn parse_server_address_inner(
address: &str,
) -> std::result::Result<(&str, u16), String> {
let (host, port_str) = if address.starts_with("[") {
let colon_index = address.find(':');
let close_bracket_index = address.rfind(']');
if colon_index.is_none() || close_bracket_index.is_none() {
return Err(format!("Invalid bracketed host/port: {address}"));
}
let close_bracket_index = close_bracket_index.unwrap();
let host = &address[1..close_bracket_index];
if close_bracket_index + 1 == address.len() {
(host, "")
} else {
if address.as_bytes().get(close_bracket_index).copied()
!= Some(b':')
{
return Err(format!(
"Only a colon may follow a close bracket: {address}"
));
}
let port_str = &address[close_bracket_index + 2..];
for c in port_str.chars() {
if !c.is_ascii_digit() {
return Err(format!("Port must be numeric: {address}"));
}
}
(host, port_str)
}
} else {
let colon_pos = address.find(':');
if let Some(colon_pos) = colon_pos {
(&address[..colon_pos], &address[colon_pos + 1..])
} else {
(address, "")
}
};
let mut port = None;
if !port_str.is_empty() {
if port_str.starts_with('+') {
return Err(format!("Unparseable port number: {port_str}"));
}
port = port_str.parse::<u16>().ok();
if port.is_none() {
return Err(format!("Unparseable port number: {port_str}"));
}
}
Ok((host, port.unwrap_or(25565)))
}
async fn resolve_server_address(
host: &str,
port: u16,
) -> Result<(String, u16)> {
static SIMULTANEOUS_DNS_QUERIES: Semaphore = Semaphore::const_new(24);
if host.parse::<Ipv4Addr>().is_ok() || host.parse::<Ipv6Addr>().is_ok() {
return Ok((host.to_owned(), port));
}
let _permit = SIMULTANEOUS_DNS_QUERIES.acquire().await?;
let resolver = hickory_resolver::TokioResolver::builder_tokio()?.build();
Ok(
match resolver.srv_lookup(format!("_minecraft._tcp.{host}")).await {
Err(e)
if e.proto()
.filter(|x| x.kind().is_no_records_found())
.is_some() =>
{
None
}
Err(e) => return Err(e.into()),
Ok(lookup) => lookup
.into_iter()
.next()
.map(|r| (r.target().to_string(), r.port())),
}
.unwrap_or_else(|| (host.to_owned(), port)),
)
}

View File

@@ -1,6 +1,5 @@
//! Minecraft CLI argument logic
use crate::launcher::quick_play_version::QuickPlayServerVersion;
use crate::launcher::{QuickPlayVersion, parse_rules};
use crate::launcher::parse_rules;
use crate::profile::QuickPlayType;
use crate::state::Credentials;
use crate::{
@@ -116,13 +115,11 @@ pub fn get_jvm_arguments(
libraries_path: &Path,
log_configs_path: &Path,
class_paths: &str,
agent_path: &Path,
version_name: &str,
memory: MemorySettings,
custom_args: Vec<String>,
java_arch: &str,
quick_play_type: &QuickPlayType,
quick_play_version: QuickPlayVersion,
log_config: Option<&LoggingConfiguration>,
) -> crate::Result<Vec<String>> {
let mut parsed_arguments = Vec::new();
@@ -158,45 +155,13 @@ pub fn get_jvm_arguments(
parsed_arguments.push("-cp".to_string());
parsed_arguments.push(class_paths.to_string());
}
parsed_arguments.push(format!("-Xmx{}M", memory.maximum));
if let Some(LoggingConfiguration::Log4j2Xml { argument, file }) = log_config
{
let full_path = log_configs_path.join(&file.id);
let full_path = full_path.to_string_lossy();
parsed_arguments.push(argument.replace("${path}", &full_path));
}
parsed_arguments.push(format!(
"-javaagent:{}",
canonicalize(agent_path)
.map_err(|_| {
crate::ErrorKind::LauncherError(format!(
"Specified Java Agent path {} does not exist",
libraries_path.to_string_lossy()
))
.as_error()
})?
.to_string_lossy()
));
parsed_arguments.push(format!(
"-Dmodrinth.internal.quickPlay.serverVersion={}",
serde_json::to_value(quick_play_version.server)?
.as_str()
.unwrap()
));
if let QuickPlayType::Server(server) = quick_play_type
&& quick_play_version.server == QuickPlayServerVersion::Injected
{
let (host, port) = server.require_resolved()?;
parsed_arguments.extend_from_slice(&[
format!("-Dmodrinth.internal.quickPlay.host={host}"),
format!("-Dmodrinth.internal.quickPlay.port={port}"),
]);
}
for arg in custom_args {
if !arg.is_empty() {
parsed_arguments.push(arg);
@@ -260,13 +225,13 @@ pub async fn get_minecraft_arguments(
resolution: WindowSize,
java_arch: &str,
quick_play_type: &QuickPlayType,
quick_play_version: QuickPlayVersion,
) -> crate::Result<Vec<String>> {
let access_token = credentials.access_token.clone();
let profile = credentials.maybe_online_profile().await;
let mut parsed_arguments = Vec::new();
if let Some(arguments) = arguments {
let mut parsed_arguments = Vec::new();
parse_arguments(
arguments,
&mut parsed_arguments,
@@ -288,7 +253,10 @@ pub async fn get_minecraft_arguments(
java_arch,
quick_play_type,
)?;
Ok(parsed_arguments)
} else if let Some(legacy_arguments) = legacy_arguments {
let mut parsed_arguments = Vec::new();
for x in legacy_arguments.split(' ') {
parsed_arguments.push(parse_minecraft_argument(
&x.replace(' ', TEMPORARY_REPLACE_CHAR),
@@ -304,21 +272,10 @@ pub async fn get_minecraft_arguments(
quick_play_type,
)?);
}
Ok(parsed_arguments)
} else {
Ok(Vec::new())
}
if let QuickPlayType::Server(server) = quick_play_type
&& quick_play_version.server == QuickPlayServerVersion::BuiltinLegacy
{
let (host, port) = server.require_resolved()?;
parsed_arguments.extend_from_slice(&[
"--server".to_string(),
host.to_string(),
"--port".to_string(),
port.to_string(),
]);
}
Ok(parsed_arguments)
}
#[allow(clippy::too_many_arguments)]
@@ -397,9 +354,9 @@ fn parse_minecraft_argument(
)
.replace(
"${quickPlayMultiplayer}",
&match quick_play_type {
QuickPlayType::Server(address) => address.to_string(),
_ => "".to_string(),
match quick_play_type {
QuickPlayType::Server(address) => address,
_ => "",
},
))
}

View File

@@ -4,9 +4,6 @@ use crate::event::emit::{emit_loading, init_or_edit_loading};
use crate::event::{LoadingBarId, LoadingBarType};
use crate::launcher::download::download_log_config;
use crate::launcher::io::IOError;
use crate::launcher::quick_play_version::{
QuickPlayServerVersion, QuickPlayVersion,
};
use crate::profile::QuickPlayType;
use crate::state::{
Credentials, JavaVersion, ProcessMetadata, ProfileInstallStage,
@@ -28,7 +25,6 @@ use tokio::process::Command;
mod args;
pub mod download;
pub mod quick_play_version;
// All nones -> disallowed
// 1+ true -> allowed
@@ -461,7 +457,7 @@ pub async fn launch_minecraft(
credentials: &Credentials,
post_exit_hook: Option<String>,
profile: &Profile,
mut quick_play_type: QuickPlayType,
quick_play_type: &QuickPlayType,
) -> crate::Result<ProcessMetadata> {
if profile.install_stage == ProfileInstallStage::PackInstalling
|| profile.install_stage == ProfileInstallStage::MinecraftInstalling
@@ -593,18 +589,6 @@ pub async fn launch_minecraft(
io::create_dir_all(&natives_dir).await?;
}
let quick_play_version =
QuickPlayVersion::find_version(version_index, &minecraft.versions);
tracing::debug!(
"Found QuickPlayVersion for {}: {quick_play_version:?}",
profile.game_version
);
if let QuickPlayType::Server(address) = &mut quick_play_type
&& quick_play_version.server >= QuickPlayServerVersion::BuiltinLegacy
{
address.resolve().await?;
}
let (main_class_keep_alive, main_class_path) =
get_resource_file!(env "JAVA_JARS_DIR" / "theseus.jar")?;
@@ -622,13 +606,11 @@ pub async fn launch_minecraft(
&java_version.architecture,
minecraft_updated,
)?,
&main_class_path,
&version_jar,
*memory,
Vec::from(java_args),
&java_version.architecture,
&quick_play_type,
quick_play_version,
quick_play_type,
version_info
.logging
.as_ref()
@@ -664,8 +646,7 @@ pub async fn launch_minecraft(
&version.type_,
*resolution,
&java_version.architecture,
&quick_play_type,
quick_play_version,
quick_play_type,
)
.await?
.into_iter(),

View File

@@ -1,102 +0,0 @@
use daedalus::minecraft::Version;
use serde::{Deserialize, Serialize};
// If modified, also update QuickPlayServerVersion.java
#[derive(
Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize,
)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum QuickPlayServerVersion {
Builtin,
BuiltinLegacy,
Injected,
Unsupported,
}
impl QuickPlayServerVersion {
pub fn min_version(&self) -> Option<&'static str> {
match self {
Self::Builtin => Some("23w14a"),
Self::BuiltinLegacy => Some("13w17a"),
Self::Injected => Some("a1.0.5_01"),
Self::Unsupported => None,
}
}
pub fn older_version(&self) -> Option<Self> {
match self {
Self::Builtin => Some(Self::BuiltinLegacy),
Self::BuiltinLegacy => Some(Self::Injected),
Self::Injected => Some(Self::Unsupported),
Self::Unsupported => None,
}
}
}
// If modified, also update QuickPlaySingleplayerVersion.java
#[derive(
Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize,
)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum QuickPlaySingleplayerVersion {
Builtin,
Unsupported,
}
impl QuickPlaySingleplayerVersion {
pub fn min_version(&self) -> Option<&'static str> {
match self {
Self::Builtin => Some("23w14a"),
Self::Unsupported => None,
}
}
pub fn older_version(&self) -> Option<Self> {
match self {
Self::Builtin => Some(Self::Unsupported),
Self::Unsupported => None,
}
}
}
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub struct QuickPlayVersion {
pub server: QuickPlayServerVersion,
pub singleplayer: QuickPlaySingleplayerVersion,
}
impl QuickPlayVersion {
pub fn find_version(version_index: usize, versions: &[Version]) -> Self {
let mut server = QuickPlayServerVersion::Builtin;
let mut server_version = server.min_version();
let mut singleplayer = QuickPlaySingleplayerVersion::Builtin;
let mut singleplayer_version = singleplayer.min_version();
for version in versions.iter().take(version_index - 1) {
if let Some(check_version) = server_version
&& version.id == check_version
{
// Safety: older_version will always be Some when min_version is Some
server = server.older_version().unwrap();
server_version = server.min_version();
}
if let Some(check_version) = singleplayer_version
&& version.id == check_version
{
singleplayer = singleplayer.older_version().unwrap();
singleplayer_version = singleplayer.min_version();
}
if server_version.is_none() && singleplayer_version.is_none() {
break;
}
}
Self {
server,
singleplayer,
}
}
}

View File

@@ -2,7 +2,7 @@ use crate::event::emit::{emit_process, emit_profile};
use crate::event::{ProcessPayloadType, ProfilePayloadType};
use crate::profile;
use crate::util::io::IOError;
use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
use chrono::{DateTime, TimeZone, Utc};
use dashmap::DashMap;
use quick_xml::Reader;
use quick_xml::events::Event;
@@ -493,16 +493,6 @@ impl Process {
if let Err(e) = Self::append_to_log_file(&log_path, &line) {
tracing::warn!("Failed to write to log file: {}", e);
}
if let Err(e) = Self::maybe_handle_old_server_join_logging(
profile_path,
line.trim_ascii_end(),
)
.await
{
tracing::error!(
"Failed to handle old server join logging: {e}"
);
}
}
line.clear();
@@ -550,6 +540,17 @@ impl Process {
timestamp: &str,
message: &str,
) -> crate::Result<()> {
let Some(host_port_string) = message.strip_prefix("Connecting to ")
else {
return Ok(());
};
let Some((host, port_string)) = host_port_string.rsplit_once(", ")
else {
return Ok(());
};
let Some(port) = port_string.parse::<u16>().ok() else {
return Ok(());
};
let timestamp = timestamp
.parse::<i64>()
.map(|x| x / 1000)
@@ -565,46 +566,6 @@ impl Process {
)
})
})?;
Self::parse_and_insert_server_join(profile_path, message, timestamp)
.await
}
async fn maybe_handle_old_server_join_logging(
profile_path: &str,
line: &str,
) -> crate::Result<()> {
if let Some((timestamp, message)) = line.split_once(" [CLIENT] [INFO] ")
{
let timestamp =
NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%d %H:%M:%S")?
.and_local_timezone(chrono::Local)
.map(|x| x.to_utc())
.single()
.unwrap_or_else(Utc::now);
Self::parse_and_insert_server_join(profile_path, message, timestamp)
.await
} else {
Self::parse_and_insert_server_join(profile_path, line, Utc::now())
.await
}
}
async fn parse_and_insert_server_join(
profile_path: &str,
message: &str,
timestamp: DateTime<Utc>,
) -> crate::Result<()> {
let Some(host_port_string) = message.strip_prefix("Connecting to ")
else {
return Ok(());
};
let Some((host, port_string)) = host_port_string.rsplit_once(", ")
else {
return Ok(());
};
let Some(port) = port_string.parse::<u16>().ok() else {
return Ok(());
};
let state = crate::State::get().await?;
crate::state::server_join_log::JoinLogEntry {

View File

@@ -1,8 +1,6 @@
<!-- TODO: After checklist v1.5, move everything into src directory. -->
# @modrinth/moderation
This package contains both the moderation checklist system used by moderators for reviewing projects on Modrinth, and the publishing checklist (nag system) that provides automated feedback to project authors during the submission process.
This package contains the moderation checklist system used for reviewing projects on Modrinth. It provides a structured and transparent way to define moderation stages, actions, and messages that are displayed to moderators during the review process.
## Structure
@@ -11,30 +9,22 @@ The package is organized as follows:
```
/packages/moderation/
├── data/
│ ├── checklist.ts # Main moderation checklist definition - imports and exports all stages
│ ├── messages/ # Markdown files containing message templates for moderation
│ ├── checklist.ts # Main checklist definition - imports and exports all stages
│ ├── messages/ # Markdown files containing message templates
│ │ ├── title/ # Messages for the title stage
│ │ ├── description/ # Messages for the description stage
│ │ └── ... # One directory per stage
── stages/ # Moderation stage definition files
├── title.ts # Title stage definition
├── description.ts # Description stage definition
└── ... # One file per stage
│ └── nags/ # Publishing checklist (nag system) files
│ ├── core.ts # Core nags (required fields, basic validation)
│ └── ...
── stages/ # Stage definition files
├── title.ts # Title stage definition
├── description.ts # Description stage definition
└── ... # One file per stage
└── types/ # Type definitions
├── actions.ts # Action-related types (moderation)
├── messages.ts # Message-related types (moderation)
── stage.ts # Stage-related types (moderation)
└── nags.ts # Nag-related types (publishing checklist)
├── actions.ts # Action-related types
├── messages.ts # Message-related types
── stage.ts # Stage-related types
```
## Moderation Checklist System
The moderation checklist provides a structured and transparent way to define moderation stages, actions, and messages that are displayed to moderators during the review process.
### Stages
## Stages
A stage represents a discrete step in the moderation process, like checking a project's title, description, or links. Each stage has:
@@ -45,7 +35,7 @@ A stage represents a discrete step in the moderation process, like checking a pr
Stages are defined in individual files in the `data/stages` directory and are assembled into the complete checklist in `data/checklist.ts`.
### Actions
## Actions
Actions represent decisions moderators can make for each stage. They can be buttons, dropdowns, toggles, etc. Actions can have:
@@ -57,11 +47,11 @@ Actions represent decisions moderators can make for each stage. They can be butt
Each action requires a unique `id` field that is used for conditional logic and action relationships. The `suggestedStatus` and `severity` fields help determine the overall moderation outcome.
### Messages
## Messages
Messages are the actual text that will be included in communications to project authors. To promote maintainability and reuse, messages are stored as Markdown files in the `data/messages` directory, organized by stage.
#### Variable replacement
### Variable replacement
You can use variables in your messages that will be replaced with user input:
@@ -91,11 +81,11 @@ More text after the variable.
The `%MESSAGE%` placeholder will be replaced with the text entered by the moderator.
### Conditional logic
## Conditional logic
The moderation system supports conditional behavior that changes based on the selection of other actions.
#### Conditional messages
### Conditional messages
You can define different messages for an action based on other selected actions:
@@ -118,7 +108,7 @@ You can define different messages for an action based on other selected actions:
}
```
#### Enabling and disabling actions
### Enabling and disabling actions
Actions can enable or disable other actions when selected:
@@ -141,7 +131,7 @@ Actions can enable or disable other actions when selected:
}
```
#### Conditional text inputs
### Conditional text inputs
Text inputs can be conditionally shown based on selected actions:
@@ -157,86 +147,3 @@ relevantExtraInput: [
},
]
```
## Publishing Checklist (Nag System)
The nag system provides automated feedback to project authors during the submission process, helping them improve their projects before they reach moderation. It analyzes project data and provides suggestions, warnings, and requirements.
### Nags
A nag represents a specific issue or suggestion for improvement. Each nag has:
- A unique `id` for identification
- A `title` and `description` displayed to the user
- A `status` indicating severity: `'required'`, `'warning'`, or `'suggestion'`
- A `shouldShow` function that determines when the nag should be displayed
- An optional `link` to help users address the issue
### Internationalization
Use vintl's `defineMessage` syntax.
If you want to use context in the messages, you can do so like this:
```typescript
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
return formatMessage(defineMessage(...), {
length: context.project.body?.length || 0,
minChars: MIN_DESCRIPTION_CHARS,
})
}
```
### Nag Context
The `NagContext` type provides access to:
- `project`: Current project data
- `versions`: Project versions
- `tags`: Frontend "tags" (generated state)
- `currentRoute`: Current page route
- and other data...
### Adding New Nags
To add a new nag:
1. Add the nag definition to the appropriate category file (or make a new category file and add it to `data/nags.ts`)
2. Add corresponding i18n messages to the `.i18n.ts` file
3. Implement the `shouldShow` logic based on project state
4. Add appropriate links to help users resolve the issue
5. Run `pnpm run fix` to fix lint issues & generate the root locale index.json file.
Example:
```typescript
// In description.ts
{
id: 'new-nag',
title: messages.newNagTitle,
description: messages.newNagDescription,
status: 'warning',
shouldShow: (context: NagContext) => {
// Your validation logic here
return someCondition
},
link: {
path: 'settings/description',
title: messages.editDescriptionTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
},
}
```
```typescript
// In description.i18n.ts
newNagTitle: {
id: 'nags.new-nag.title',
defaultMessage: 'New Nag Title',
},
newNagDescription: {
id: 'nags.new-nag.description',
defaultMessage: 'Description of the new nag issue.',
```

View File

@@ -4,7 +4,7 @@ Per section 5.1 of %RULES%, it is important that the metadata of your projects i
For a brief rundown of how this works:
- Some modpacks can be client-side, usually aimed at providing utility and optimization while allowing the player to join an unmodded server, for instance, [Fabulously Optimized](https://modrinth.com/project/1KVo5zza).
- Most other modpacks that change how the game is played are going to be required on both the client and server, like the modpack [Aged](https://modrinth.com/project/i4XHCd7Q).
- Some modpacks can be client-side, usually aimed at providing utility and optimization while allowing the player to join an unmodded server, for instance, [Fabulously Optimized](https://modrinth.com/modpack/fabulously-optimized).
- Most other modpacks that change how the game is played are going to be required on both the client and server, like the modpack [Dying Light](https://modrinth.com/modpack/dying-light).
When in doubt, test for yourself or check the requirements of the mods in your pack.

View File

@@ -1,7 +0,0 @@
import type { Nag } from '../types/nags'
import { coreNags } from './nags/core'
import { descriptionNags } from './nags/description'
import { linksNags } from './nags/links'
import { tagsNags } from './nags/tags'
export default [...coreNags, ...linksNags, ...descriptionNags, ...tagsNags] as Nag[]

View File

@@ -1,241 +0,0 @@
import type { Nag, NagContext } from '../../types/nags'
import { formatProjectType } from '@modrinth/utils'
import { useVIntl, defineMessage } from '@vintl/vintl'
export const coreNags: Nag[] = [
{
id: 'moderator-feedback',
title: defineMessage({
id: 'nags.moderator-feedback.title',
defaultMessage: 'Review moderator feedback',
}),
description: defineMessage({
id: 'nags.moderator-feedback.description',
defaultMessage:
'Review and address all concerns from the moderation team before resubmitting.',
}),
status: 'warning',
shouldShow: (context: NagContext) =>
context.tags.rejectedStatuses.includes(context.project.status),
link: {
path: 'moderation',
title: defineMessage({
id: 'nags.moderation.title',
defaultMessage: 'Visit moderation thread',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-moderation',
},
},
{
id: 'upload-version',
title: defineMessage({
id: 'nags.upload-version.title',
defaultMessage: 'Upload a version',
}),
description: defineMessage({
id: 'nags.upload-version.description',
defaultMessage: 'At least one version is required for a project to be submitted for review.',
}),
status: 'required',
shouldShow: (context: NagContext) => context.versions.length < 1,
link: {
path: 'versions',
title: defineMessage({
id: 'nags.versions.title',
defaultMessage: 'Visit versions page',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-versions',
},
},
{
id: 'add-description',
title: defineMessage({
id: 'nags.add-description.title',
defaultMessage: 'Add a description',
}),
description: defineMessage({
id: 'nags.add-description.description',
defaultMessage:
"A description that clearly describes the project's purpose and function is required.",
}),
status: 'required',
shouldShow: (context: NagContext) => context.project.body === '',
link: {
path: 'settings/description',
title: defineMessage({
id: 'nags.settings.description.title',
defaultMessage: 'Visit description settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
},
},
{
id: 'add-icon',
title: defineMessage({
id: 'nags.add-icon.title',
defaultMessage: 'Add an icon',
}),
description: defineMessage({
id: 'nags.add-icon.description',
defaultMessage:
'Adding a unique, relevant, and engaging icon makes your project identifiable and helps it stand out.',
}),
status: 'suggestion',
shouldShow: (context: NagContext) => !context.project.icon_url,
link: {
path: 'settings',
title: defineMessage({
id: 'nags.settings.title',
defaultMessage: 'Visit general settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
id: 'feature-gallery-image',
title: defineMessage({
id: 'nags.feature-gallery-image.title',
defaultMessage: 'Feature a gallery image',
}),
description: defineMessage({
id: 'nags.feature-gallery-image.description',
defaultMessage:
'The featured gallery image is often how your project makes its first impression.',
}),
status: 'suggestion',
shouldShow: (context: NagContext) => {
const featuredGalleryImage = context.project.gallery?.find((img) => img.featured)
return context.project?.gallery?.length === 0 || !featuredGalleryImage
},
link: {
path: 'gallery',
title: defineMessage({
id: 'nags.gallery.title',
defaultMessage: 'Visit gallery page',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-gallery',
},
},
{
id: 'select-tags',
title: defineMessage({
id: 'nags.select-tags.title',
defaultMessage: 'Select tags',
}),
description: defineMessage({
id: 'nags.select-tags.description',
defaultMessage:
'Select the tags that correctly apply to your project to help the right users find it.',
}),
status: 'suggestion',
shouldShow: (context: NagContext) =>
context.project.versions.length > 0 && context.project.categories.length < 1,
link: {
path: 'settings/tags',
title: defineMessage({
id: 'nags.settings.tags.title',
defaultMessage: 'Visit tag settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-tags',
},
},
{
id: 'add-links',
title: defineMessage({
id: 'nags.add-links.title',
defaultMessage: 'Add external links',
}),
description: defineMessage({
id: 'nags.add-links.description',
defaultMessage:
'Add any relevant links targeted outside of Modrinth, such as source code, an issue tracker, or a Discord invite.',
}),
status: 'suggestion',
shouldShow: (context: NagContext) =>
!(
context.project.issues_url ||
context.project.source_url ||
context.project.wiki_url ||
context.project.discord_url ||
context.project.donation_urls.length > 0
),
link: {
path: 'settings/links',
title: defineMessage({
id: 'nags.settings.links.title',
defaultMessage: 'Visit links settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-links',
},
},
{
id: 'select-environments',
title: defineMessage({
id: 'nags.select-environments.title',
defaultMessage: 'Select supported environments',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
return formatMessage(
defineMessage({
id: 'nags.select-environments.description',
defaultMessage: `Select if the {projectType} functions on the client-side and/or server-side.`,
}),
{
projectType: formatProjectType(context.project.project_type).toLowerCase(),
},
)
},
status: 'required',
shouldShow: (context: NagContext) => {
const excludedTypes = ['resourcepack', 'plugin', 'shader', 'datapack']
return (
context.project.versions.length > 0 &&
!excludedTypes.includes(context.project.project_type) &&
(context.project.client_side === 'unknown' ||
context.project.server_side === 'unknown' ||
(context.project.client_side === 'unsupported' &&
context.project.server_side === 'unsupported'))
)
},
link: {
path: 'settings',
title: defineMessage({
id: 'nags.settings.environments.title',
defaultMessage: 'Visit general settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
id: 'select-license',
title: defineMessage({
id: 'nags.select-license.title',
defaultMessage: 'Select a license',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
return formatMessage(
defineMessage({
id: 'nags.select-license.description',
defaultMessage: 'Select the license your {projectType} is distributed under.',
}),
{
projectType: formatProjectType(context.project.project_type).toLowerCase(),
},
)
},
status: 'required',
shouldShow: (context: NagContext) => context.project.license.id === 'LicenseRef-Unknown',
link: {
path: 'settings/license',
title: defineMessage({
id: 'nags.settings.license.title',
defaultMessage: 'Visit license settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-license',
},
},
]

View File

@@ -1,343 +0,0 @@
import { renderHighlightedString } from '@modrinth/utils'
import type { Nag, NagContext } from '../../types/nags'
import { useVIntl, defineMessage } from '@vintl/vintl'
export const MIN_DESCRIPTION_CHARS = 200
export const MAX_HEADER_LENGTH = 80
export const MIN_SUMMARY_CHARS = 30
export const MIN_CHARS_PER_IMAGE = 60
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 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 isVeryLong = headerText.length > MAX_HEADER_LENGTH
const hasMultipleSentences = sentences.length > 1
if (isVeryLong || hasMultipleSentences) {
longHeaders.push(headerText)
}
})
return {
hasLongHeaders: longHeaders.length > 0,
longHeaders,
}
}
export 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 recommendedTextLength = MIN_CHARS_PER_IMAGE * totalImages
const imageHeavy =
recommendedTextLength > MIN_DESCRIPTION_CHARS && textLength < recommendedTextLength
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 function countText(markdown: string): number {
const htmlString = renderHighlightedString(markdown)
const parser = new DOMParser()
const doc = parser.parseFromString(htmlString, 'text/html')
const walker = document.createTreeWalker(doc, NodeFilter.SHOW_TEXT)
const textList: string[] = []
let currentNode: Node | null = walker.currentNode
while (currentNode) {
if (currentNode.textContent !== null) {
textList.push(currentNode.textContent)
}
currentNode = walker.nextNode()
}
return textList.join(' ').trim().length
}
export const descriptionNags: Nag[] = [
{
id: 'description-too-short',
title: defineMessage({
id: 'nags.description-too-short.title',
defaultMessage: 'Expand the description',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const readableLength = countText(context.project.body || '')
return formatMessage(
defineMessage({
id: 'nags.description-too-short.description',
defaultMessage:
'Your description is {length} readable characters. At least {minChars} characters is recommended to create a clear and informative description.',
}),
{
length: readableLength,
minChars: MIN_DESCRIPTION_CHARS,
},
)
},
status: 'warning',
shouldShow: (context: NagContext) => {
const readableLength = countText(context.project.body || '')
return readableLength < MIN_DESCRIPTION_CHARS && readableLength > 0
},
link: {
path: 'settings/description',
title: defineMessage({
id: 'nags.edit-description.title',
defaultMessage: 'Edit description',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
},
},
{
id: 'long-headers',
title: defineMessage({
id: 'nags.long-headers.title',
defaultMessage: 'Shorten headers',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const { longHeaders } = analyzeHeaderLength(context.project.body || '')
const count = longHeaders.length
return formatMessage(
defineMessage({
id: 'nags.long-headers.description',
defaultMessage:
'{count, plural, one {# header} other {# headers}} in your description {count, plural, one {is} other {are}} too long. Headers should be concise and act as section titles, not full sentences.',
}),
{
count,
},
)
},
status: 'warning',
shouldShow: (context: NagContext) => {
const { hasLongHeaders } = analyzeHeaderLength(context.project.body || '')
return hasLongHeaders
},
link: {
path: 'settings/description',
title: defineMessage({
id: 'nags.edit-description.title',
defaultMessage: 'Edit description',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
},
},
{
id: 'summary-too-short',
title: defineMessage({
id: 'nags.summary-too-short.title',
defaultMessage: 'Expand the summary',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
return formatMessage(
defineMessage({
id: 'nags.summary-too-short.description',
defaultMessage:
'Your summary is {length} characters. At least {minChars} characters is recommended to create an informative and enticing summary.',
}),
{
length: context.project.description?.length || 0,
minChars: MIN_SUMMARY_CHARS,
},
)
},
status: 'warning',
shouldShow: (context: NagContext) => {
const summaryLength = context.project.description?.trim()?.length || 0
return summaryLength < MIN_SUMMARY_CHARS && summaryLength !== 0
},
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-summary.title',
defaultMessage: 'Edit summary',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
id: 'minecraft-title-clause',
title: defineMessage({
id: 'nags.minecraft-title-clause.title',
defaultMessage: 'Avoid brand infringement',
}),
description: defineMessage({
id: 'nags.minecraft-title-clause.description',
defaultMessage: `Projects must not use Minecraft's branding or include "Minecraft" as a significant part of the name.`,
}),
status: 'warning',
shouldShow: (context: NagContext) => {
const title = context.project.title?.toLowerCase() || ''
const wordsInTitle = title.split(' ').filter((word) => word.length > 0)
return title.includes('minecraft') && title.length > 0 && wordsInTitle.length <= 3
},
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-title.title',
defaultMessage: 'Edit title',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
id: 'title-contains-technical-info',
title: defineMessage({
id: 'nags.title-contains-technical-info.title',
defaultMessage: 'Clean up the name',
}),
description: defineMessage({
id: 'nags.title-contains-technical-info.description',
defaultMessage:
"Keeping your project's Name clean and makes it memorable 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: { name: string }) => 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: defineMessage({
id: 'nags.edit-title.title',
defaultMessage: 'Edit title',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
id: 'summary-same-as-title',
title: defineMessage({
id: 'nags.summary-same-as-title.title',
defaultMessage: 'Make the summary unique',
}),
description: defineMessage({
id: 'nags.summary-same-as-title.description',
defaultMessage:
"Your summary can not be the same as your project's Name. It's important to create an informative and enticing Summary.",
}),
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: defineMessage({
id: 'nags.edit-summary.title',
defaultMessage: 'Edit summary',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
// Don't like this one, is this needed?
id: 'image-heavy-description',
title: defineMessage({
id: 'nags.image-heavy-description.title',
defaultMessage: 'Ensure accessibility',
}),
description: defineMessage({
id: 'nags.image-heavy-description.description',
defaultMessage:
'Your Description should contain sufficient plain text or image alt-text, keeping it accessible to 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: defineMessage({
id: 'nags.edit-description.title',
defaultMessage: 'Edit description',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
},
},
{
id: 'missing-alt-text',
title: defineMessage({
id: 'nags.missing-alt-text.title',
defaultMessage: 'Add image alt text',
}),
description: defineMessage({
id: 'nags.missing-alt-text.description',
defaultMessage:
'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: defineMessage({
id: 'nags.edit-description.title',
defaultMessage: 'Edit description',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
},
},
]

View File

@@ -1,4 +0,0 @@
export * from './core'
export * from './links'
export * from './description'
export * from './tags'

View File

@@ -1,269 +0,0 @@
import type { Nag, NagContext } from '../../types/nags'
import { formatProjectType } from '@modrinth/utils'
import { useVIntl, defineMessage } from '@vintl/vintl'
export const commonLinkDomains = {
source: ['github.com', 'gitlab.com', 'bitbucket.org', 'codeberg.org', 'git.sr.ht'],
issues: ['github.com', 'gitlab.com', 'bitbucket.org', 'codeberg.org', 'docs.google.com'],
discord: ['discord.gg', 'discord.com', 'dsc.gg'],
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',
'google.com',
'example.com',
't.me',
],
linkShorteners: ['bit.ly', 'adf.ly', 'tinyurl.com', 'short.io', 'is.gd'],
}
export function isCommonUrl(url: string | null, commonDomains: string[]): boolean {
if (url === null || url === '') return true
try {
const domain = new URL(url).hostname.toLowerCase()
return commonDomains.some((allowed) => domain.includes(allowed))
} catch {
return false
}
}
export function isCommonUrlOfType(url: string | null, commonDomains: string[]): boolean {
if (url === null || url === '') return false
return isCommonUrl(url, commonDomains)
}
export function isDiscordUrl(url: string | null): boolean {
return isCommonUrlOfType(url, commonLinkDomains.discord)
}
export function isLinkShortener(url: string | null): boolean {
return isCommonUrlOfType(url, commonLinkDomains.linkShorteners)
}
export function isUncommonLicenseUrl(url: string | null): boolean {
return isCommonUrlOfType(url, commonLinkDomains.licenseBlocklist)
}
export const linksNags: Nag[] = [
{
id: 'verify-external-links',
title: defineMessage({
id: 'nags.verify-external-links.title',
defaultMessage: 'Verify external links',
}),
description: defineMessage({
id: 'nags.verify-external-links.description',
defaultMessage:
'Some of your external links may be using domains that are inappropriate for that type of link.',
}),
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: defineMessage({
id: 'nags.visit-links-settings.title',
defaultMessage: 'Visit links settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-links',
},
},
{
id: 'misused-discord-link',
title: defineMessage({
id: 'nags.misused-discord-link.title',
defaultMessage: 'Move Discord invite',
}),
description: defineMessage({
id: 'nags.misused-discord-link-description',
defaultMessage:
'Discord invites can not be used for other link types. Please put your Discord link in the Discord Invite link field only.',
}),
status: 'required',
shouldShow: (context: NagContext) =>
isDiscordUrl(context.project.source_url) ||
isDiscordUrl(context.project.issues_url) ||
isDiscordUrl(context.project.wiki_url),
link: {
path: 'settings/links',
title: defineMessage({
id: 'nags.visit-links-settings.title',
defaultMessage: 'Visit links settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-links',
},
},
{
id: 'link-shortener-usage',
title: defineMessage({
id: 'nags.link-shortener-usage.title',
defaultMessage: "Don't use link shorteners",
}),
description: defineMessage({
id: 'nags.link-shortener-usage.description',
defaultMessage:
'Use of link shorteners or other methods to obscure where a link may lead in your external links or license link is prohibited, please only use appropriate full length links.',
}),
status: 'required',
shouldShow: (context: NagContext) =>
isLinkShortener(context.project.source_url) ||
isLinkShortener(context.project.issues_url) ||
isLinkShortener(context.project.wiki_url) ||
Boolean(context.project.license.url && isLinkShortener(context.project.license.url)),
},
{
id: 'invalid-license-url',
title: defineMessage({
id: 'nags.invalid-license-url.title',
defaultMessage: 'Add a valid license link',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const licenseUrl = context.project.license.url
if (!licenseUrl) {
return formatMessage(
defineMessage({
id: 'nags.invalid-license-url.description.default',
defaultMessage: 'License URL is invalid.',
}),
)
}
try {
const domain = new URL(licenseUrl).hostname.toLowerCase()
return formatMessage(
defineMessage({
id: 'nags.invalid-license-url.description.domain',
defaultMessage:
'Your license URL points to {domain}, which is not appropriate for license information. License URLs should link directly to your license file, not social media, gaming platforms, etc.',
}),
{ domain },
)
} catch {
return formatMessage(
defineMessage({
id: 'nags.invalid-license-url.description.malformed',
defaultMessage:
'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)
try {
new URL(licenseUrl)
return isBlocklisted
} catch {
return true
}
},
link: {
path: 'settings',
title: defineMessage({
id: 'nags.edit-license.title',
defaultMessage: 'Edit license',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
id: 'gpl-license-source-required',
title: defineMessage({
id: 'nags.gpl-license-source-required.title',
defaultMessage: 'Provide source code',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
return formatMessage(
defineMessage({
id: 'nags.gpl-license-source-required.description',
defaultMessage:
'Your {projectType} uses a license which requires source code to be available. Please provide a source code link or sources file for each additional version, or consider using a different license.',
}),
{
projectType: formatProjectType(context.project.project_type).toLowerCase(),
},
)
},
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',
'MPL-2.0',
]
const isGplLicense = gplLicenses.includes(context.project.license.id)
const hasSourceUrl = !!context.project.source_url
const hasAdditionalFiles = (context: NagContext) => {
let hasAdditional = true
context.versions.forEach((version) => {
if (version.files.length < 2) hasAdditional = false
})
return hasAdditional
}
const notSourceAsDistributed = (context: NagContext) =>
context.project.project_type === 'mod' || context.project.project_type === 'plugin'
return (
isGplLicense &&
notSourceAsDistributed(context) &&
!hasSourceUrl &&
!hasAdditionalFiles(context)
)
},
link: {
path: 'settings/links',
title: defineMessage({
id: 'nags.visit-links-settings.title',
defaultMessage: 'Visit links settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-links',
},
},
]

View File

@@ -1,160 +0,0 @@
import type { Project } from '@modrinth/utils'
import type { Nag, NagContext } from '../../types/nags'
import { useVIntl, defineMessage } from '@vintl/vintl'
const allResolutionTags = ['8x-', '16x', '32x', '48x', '64x', '128x', '256x', '512x+']
const MAX_TAG_COUNT = 8
function getCategories(
project: Project & { actualProjectType: string },
tags: {
categories?: {
project_type: string
}[]
},
) {
return (
tags.categories?.filter(
(category: { project_type: string }) => category.project_type === project.actualProjectType,
) ?? []
)
}
export const tagsNags: Nag[] = [
{
id: 'too-many-tags',
title: defineMessage({
id: 'nags.too-many-tags.title',
defaultMessage: 'Select accurate tags',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const tagCount =
context.project.categories.length + (context.project.additional_categories?.length || 0)
const maxTagCount = MAX_TAG_COUNT
return formatMessage(
defineMessage({
id: 'nags.too-many-tags.description',
defaultMessage:
"You've selected {tagCount} tags. Consider reducing to {maxTagCount} or fewer to make sure your project appears in relevant search results.",
}),
{
tagCount,
maxTagCount,
},
)
},
status: 'warning',
shouldShow: (context: NagContext) => {
const tagCount =
context.project.categories.length + (context.project.additional_categories?.length || 0)
return tagCount > MAX_TAG_COUNT
},
link: {
path: 'settings/tags',
title: defineMessage({
id: 'nags.edit-tags.title',
defaultMessage: 'Edit tags',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-tags',
},
},
{
id: 'multiple-resolution-tags',
title: defineMessage({
id: 'nags.multiple-resolution-tags.title',
defaultMessage: 'Select correct resolution',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const resolutionTags = context.project.categories
.concat(context.project.additional_categories)
.filter((tag: string) => allResolutionTags.includes(tag))
const sortedTags = resolutionTags.toSorted((a, b) => {
return allResolutionTags.indexOf(a) - allResolutionTags.indexOf(b)
})
return formatMessage(
defineMessage({
id: 'nags.multiple-resolution-tags.description',
defaultMessage:
"You've selected {count} resolution tags ({tags}). Resource packs should typically only have one resolution tag that matches their primary resolution.",
}),
{
count: resolutionTags.length,
tags: sortedTags
.join(', ')
.replace('8x-', '8x or lower')
.replace('512x+', '512x or higher'),
},
)
},
status: 'warning',
shouldShow: (context: NagContext) => {
if (context.project.project_type !== 'resourcepack') return false
const resolutionTags = context.project.categories
.concat(context.project.additional_categories)
.filter((tag: string) => allResolutionTags.includes(tag))
return resolutionTags.length > 1
},
link: {
path: 'settings/tags',
title: defineMessage({
id: 'nags.edit-tags.title',
defaultMessage: 'Edit tags',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-tags',
},
},
{
id: 'all-tags-selected',
title: defineMessage({
id: 'nags.all-tags-selected.title',
defaultMessage: 'Select accurate tags',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const categoriesForProjectType = getCategories(
context.project as Project & { actualProjectType: string },
context.tags,
)
const totalAvailableTags = categoriesForProjectType.length
return formatMessage(
defineMessage({
id: 'nags.all-tags-selected.description',
defaultMessage:
"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 are relevant to your project.",
}),
{
totalAvailableTags,
},
)
},
status: 'required',
shouldShow: (context: NagContext) => {
const categoriesForProjectType = getCategories(
context.project as Project & { actualProjectType: string },
context.tags,
)
const totalSelectedTags =
context.project.categories.length + (context.project.additional_categories?.length || 0)
return (
totalSelectedTags === categoriesForProjectType.length &&
context.project.project_type !== 'project'
)
},
link: {
path: 'settings/tags',
title: defineMessage({
id: 'nags.edit-tags.title',
defaultMessage: 'Edit tags',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-tags',
},
},
]

View File

@@ -2,12 +2,9 @@ export * from './types/actions'
export * from './types/messages'
export * from './types/stage'
export * from './types/keybinds'
export * from './types/nags'
export * from './types/reports'
export * from './utils'
export * from './data/nags/index'
export { default as nags } from './data/nags'
export { finalPermissionMessages } from './data/modpack-permissions-stage'
export { default as checklist } from './data/checklist'
export { default as keybinds } from './data/keybinds'

View File

@@ -1,203 +0,0 @@
{
"nags.add-description.description": {
"defaultMessage": "A description that clearly describes the project's purpose and function is required."
},
"nags.add-description.title": {
"defaultMessage": "Add a description"
},
"nags.add-icon.description": {
"defaultMessage": "Adding a unique, relevant, and engaging icon makes your project identifiable and helps it stand out."
},
"nags.add-icon.title": {
"defaultMessage": "Add an icon"
},
"nags.add-links.description": {
"defaultMessage": "Add any relevant links targeted outside of Modrinth, such as source code, an issue tracker, or a Discord invite."
},
"nags.add-links.title": {
"defaultMessage": "Add external links"
},
"nags.all-tags-selected.description": {
"defaultMessage": "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 are relevant to your project."
},
"nags.all-tags-selected.title": {
"defaultMessage": "Select accurate tags"
},
"nags.description-too-short.description": {
"defaultMessage": "Your description is {length} readable characters. At least {minChars} characters is recommended to create a clear and informative description."
},
"nags.description-too-short.title": {
"defaultMessage": "Expand the description"
},
"nags.edit-description.title": {
"defaultMessage": "Edit description"
},
"nags.edit-license.title": {
"defaultMessage": "Edit license"
},
"nags.edit-summary.title": {
"defaultMessage": "Edit summary"
},
"nags.edit-tags.title": {
"defaultMessage": "Edit tags"
},
"nags.edit-title.title": {
"defaultMessage": "Edit title"
},
"nags.feature-gallery-image.description": {
"defaultMessage": "The featured gallery image is often how your project makes its first impression."
},
"nags.feature-gallery-image.title": {
"defaultMessage": "Feature a gallery image"
},
"nags.gallery.title": {
"defaultMessage": "Visit gallery page"
},
"nags.gpl-license-source-required.description": {
"defaultMessage": "Your {projectType} uses a license which requires source code to be available. Please provide a source code link or sources file for each additional version, or consider using a different license."
},
"nags.gpl-license-source-required.title": {
"defaultMessage": "Provide source code"
},
"nags.image-heavy-description.description": {
"defaultMessage": "Your Description should contain sufficient plain text or image alt-text, keeping it accessible to those using screen readers or with slow internet connections."
},
"nags.image-heavy-description.title": {
"defaultMessage": "Ensure accessibility"
},
"nags.invalid-license-url.description.default": {
"defaultMessage": "License URL is invalid."
},
"nags.invalid-license-url.description.domain": {
"defaultMessage": "Your license URL points to {domain}, which is not appropriate for license information. License URLs should link directly to your license file, not social media, gaming platforms, etc."
},
"nags.invalid-license-url.description.malformed": {
"defaultMessage": "Your license URL appears to be malformed. Please provide a valid URL to your license text."
},
"nags.invalid-license-url.title": {
"defaultMessage": "Add a valid license link"
},
"nags.link-shortener-usage.description": {
"defaultMessage": "Use of link shorteners or other methods to obscure where a link may lead in your external links or license link is prohibited, please only use appropriate full length links."
},
"nags.link-shortener-usage.title": {
"defaultMessage": "Don't use link shorteners"
},
"nags.long-headers.description": {
"defaultMessage": "{count, plural, one {# header} other {# headers}} in your description {count, plural, one {is} other {are}} too long. Headers should be concise and act as section titles, not full sentences."
},
"nags.long-headers.title": {
"defaultMessage": "Shorten headers"
},
"nags.minecraft-title-clause.description": {
"defaultMessage": "Projects must not use Minecraft's branding or include \"Minecraft\" as a significant part of the name."
},
"nags.minecraft-title-clause.title": {
"defaultMessage": "Avoid brand infringement"
},
"nags.missing-alt-text.description": {
"defaultMessage": "Some of your images are missing alt text, which is important for accessibility, especially for visually impaired users."
},
"nags.missing-alt-text.title": {
"defaultMessage": "Add image alt text"
},
"nags.misused-discord-link-description": {
"defaultMessage": "Discord invites can not be used for other link types. Please put your Discord link in the Discord Invite link field only."
},
"nags.misused-discord-link.title": {
"defaultMessage": "Move Discord invite"
},
"nags.moderation.title": {
"defaultMessage": "Visit moderation thread"
},
"nags.moderator-feedback.description": {
"defaultMessage": "Review and address all concerns from the moderation team before resubmitting."
},
"nags.moderator-feedback.title": {
"defaultMessage": "Review moderator feedback"
},
"nags.multiple-resolution-tags.description": {
"defaultMessage": "You've selected {count} resolution tags ({tags}). Resource packs should typically only have one resolution tag that matches their primary resolution."
},
"nags.multiple-resolution-tags.title": {
"defaultMessage": "Select correct resolution"
},
"nags.select-environments.description": {
"defaultMessage": "Select if the {projectType} functions on the client-side and/or server-side."
},
"nags.select-environments.title": {
"defaultMessage": "Select supported environments"
},
"nags.select-license.description": {
"defaultMessage": "Select the license your {projectType} is distributed under."
},
"nags.select-license.title": {
"defaultMessage": "Select a license"
},
"nags.select-tags.description": {
"defaultMessage": "Select the tags that correctly apply to your project to help the right users find it."
},
"nags.select-tags.title": {
"defaultMessage": "Select tags"
},
"nags.settings.description.title": {
"defaultMessage": "Visit description settings"
},
"nags.settings.environments.title": {
"defaultMessage": "Visit general settings"
},
"nags.settings.license.title": {
"defaultMessage": "Visit license settings"
},
"nags.settings.links.title": {
"defaultMessage": "Visit links settings"
},
"nags.settings.tags.title": {
"defaultMessage": "Visit tag settings"
},
"nags.settings.title": {
"defaultMessage": "Visit general settings"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "Your summary can not be the same as your project's Name. It's important to create an informative and enticing Summary."
},
"nags.summary-same-as-title.title": {
"defaultMessage": "Make the summary unique"
},
"nags.summary-too-short.description": {
"defaultMessage": "Your summary is {length} characters. At least {minChars} characters is recommended to create an informative and enticing summary."
},
"nags.summary-too-short.title": {
"defaultMessage": "Expand the summary"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "Keeping your project's Name clean and makes it memorable easier to find. Version and loader information is automatically displayed alongside your project."
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "Clean up the name"
},
"nags.too-many-tags.description": {
"defaultMessage": "You've selected {tagCount} tags. Consider reducing to {maxTagCount} or fewer to make sure your project appears in relevant search results."
},
"nags.too-many-tags.title": {
"defaultMessage": "Select accurate tags"
},
"nags.upload-version.description": {
"defaultMessage": "At least one version is required for a project to be submitted for review."
},
"nags.upload-version.title": {
"defaultMessage": "Upload a version"
},
"nags.verify-external-links.description": {
"defaultMessage": "Some of your external links may be using domains that are inappropriate for that type of link."
},
"nags.verify-external-links.title": {
"defaultMessage": "Verify external links"
},
"nags.versions.title": {
"defaultMessage": "Visit versions page"
},
"nags.visit-links-settings.title": {
"defaultMessage": "Visit links settings"
}
}

View File

@@ -6,17 +6,14 @@
"types": "./index.d.ts",
"scripts": {
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write . && pnpm run intl:extract",
"intl:extract": "formatjs extract \"**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore \"node_modules/**/*\" --out-file locales/en-US/index.json --preserve-whitespace"
"fix": "eslint . --fix && prettier --write ."
},
"dependencies": {
"@modrinth/assets": "workspace:*",
"@modrinth/utils": "workspace:*",
"@modrinth/assets": "workspace:*",
"vue": "^3.5.13"
},
"devDependencies": {
"@formatjs/cli": "^6.2.12",
"@vintl/vintl": "^4.4.1",
"eslint": "^8.57.0",
"eslint-config-custom": "workspace:*",
"tsconfig": "workspace:*"

View File

@@ -1,96 +0,0 @@
import type { Project, User, Version } from '@modrinth/utils'
import type { MessageDescriptor } from '@vintl/vintl'
import type { FunctionalComponent, SVGAttributes } from 'vue'
/**
* Type which represents the status type of a nag.
*
* - `required` indicates that the nag must be addressed.
* - `warning` indicates that the nag is important but not critical, and can be ignored. It is often used for issues that should be resolved but do not block project submission.
* - `suggestion` indicates that the nag is a recommendation and can be ignored.
*/
export type NagStatus = 'required' | 'warning' | 'suggestion' | 'special-submit-action'
/**
* Interface representing the context in which a nag is displayed.
* It includes the project, versions, current member, all members, and the current route.
* This context is used to determine whether a nag or it's link should be shown and how it should be presented.
*/
export interface NagContext {
/**
* The project associated with the nag.
*/
project: Project
/**
* The versions associated with the project.
*/
versions: Version[]
/**
* The current project member viewing the nag.
*/
currentMember: User
/**
* The current route in the application.
*/
currentRoute: string
/* eslint-disable @typescript-eslint/no-explicit-any */
tags: any
submitProject: (...any: any) => any
/* eslint-enable @typescript-eslint/no-explicit-any */
}
/**
* Interface representing a nag's link.
*/
export interface NagLink {
/**
* A relative path to the nag's link, e.g. '/settings'.
*/
path: string
/**
* The text to display for the nag's link.
*/
title: MessageDescriptor | string
/**
* The status of the nag, which can be 'required', 'warning', or 'suggestion'.
*/
shouldShow?: (context: NagContext) => boolean
}
/**
* Interface representing a nag.
*/
export interface Nag {
/**
* A unique identifier for the nag.
*/
id: string
/**
* The title of the nag.
*/
title: MessageDescriptor | string
/**
* A function that returns the description of the nag.
* It can accept a context to provide dynamic descriptions.
*/
description: MessageDescriptor | ((context: NagContext) => string)
/**
* The status of the nag, which can be 'required', 'warning', or 'suggestion'.
*/
status: NagStatus
/**
* An optional icon for the nag, usually from `@modrinth/assets`.
* If not specified it will use the default icon associated with the nag status.
*/
icon?: FunctionalComponent<SVGAttributes>
/**
* A function that determines whether the nag should be shown based on the context.
*/
shouldShow: (context: NagContext) => boolean
/**
* An optional link associated with the nag.
* If provided, it should be displayed alongside the nag.
*/
link?: NagLink
}

View File

@@ -10,23 +10,6 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2025-08-01T21:30:00-04:00`,
product: 'web',
body: `### Improvements
- Fixed issues with the newsletter subscription checkbox & buttons on news pages. ([#4072](https://github.com/modrinth/code/pull/4072), [#4073](https://github.com/modrinth/code/pull/4073))
- You can now access the "Moderation" tab on project pages again even if your project is approved. ([#4067](https://github.com/modrinth/code/pull/4067))
- Fixed issues with collection visibility. ([#4070](https://github.com/modrinth/code/pull/4070))
- Fixed text issue on collection icon upload dropdown. ([#4069](https://github.com/modrinth/code/pull/4069))`,
},
{
date: `2025-08-01T21:30:00-04:00`,
product: 'servers',
body: `### Improvements
- Server status information is now correctly displayed in the 'My Servers' page. ([#4071](https://github.com/modrinth/code/pull/4071))
- Fixed an error with displaying startup settings.
- Improved ratelimit error message.`,
},
{
date: `2025-07-19T15:20:00-07:00`,
product: 'web',

View File

@@ -18,14 +18,7 @@ export type DonationPlatform =
| { short: 'ko-fi'; name: 'Ko-fi' }
| { short: 'other'; name: 'Other' }
export type ProjectType =
| 'mod'
| 'modpack'
| 'resourcepack'
| 'shader'
| 'plugin'
| 'datapack'
| 'project'
export type ProjectType = 'mod' | 'modpack' | 'resourcepack' | 'shader' | 'plugin' | 'datapack'
export type MonetizationStatus = 'monetized' | 'demonetized' | 'force-demonetized'
export type GameVersion = string
@@ -77,10 +70,10 @@ export interface Project {
thread_id: ModrinthId
organization: ModrinthId
issues_url: string | null
source_url: string | null
wiki_url: string | null
discord_url: string | null
issues_url?: string
source_url?: string
wiki_url?: string
discord_url?: string
donation_urls: DonationLink<DonationPlatform>[]
published: string

48
pnpm-lock.yaml generated
View File

@@ -479,12 +479,6 @@ importers:
specifier: ^3.5.13
version: 3.5.13(typescript@5.8.3)
devDependencies:
'@formatjs/cli':
specifier: ^6.2.12
version: 6.2.12(@vue/compiler-core@3.5.13)(vue@3.5.13(typescript@5.8.3))
'@vintl/vintl':
specifier: ^4.4.1
version: 4.4.1(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3))
eslint:
specifier: ^8.57.0
version: 8.57.0
@@ -4178,8 +4172,8 @@ packages:
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
electron-to-chromium@1.5.182:
resolution: {integrity: sha512-Lv65Btwv9W4J9pyODI6EWpdnhfvrve/us5h1WspW8B2Fb0366REPtY3hX7ounk1CkV/TBjWCEvCBBbYbmV0qCA==}
electron-to-chromium@1.5.191:
resolution: {integrity: sha512-xcwe9ELcuxYLUFqZZxL19Z6HVKcvNkIwhbHUz7L3us6u12yR+7uY89dSl570f/IqNthx8dAw3tojG7i4Ni4tDA==}
electron-to-chromium@1.5.71:
resolution: {integrity: sha512-dB68l59BI75W1BUGVTAEJy45CEVuEGy9qPVVQ8pnHyHMn36PLPPoE1mjLH+lo9rKulO3HC2OhbACI/8tCqJBcA==}
@@ -8969,10 +8963,6 @@ snapshots:
dependencies:
vue: 3.5.13(typescript@5.5.4)
'@braw/async-computed@5.0.2(vue@3.5.13(typescript@5.8.3))':
dependencies:
vue: 3.5.13(typescript@5.8.3)
'@cloudflare/kv-asset-handler@0.3.4':
dependencies:
mime: 3.0.0
@@ -9500,11 +9490,6 @@ snapshots:
'@vue/compiler-core': 3.5.13
vue: 3.5.13(typescript@5.5.4)
'@formatjs/cli@6.2.12(@vue/compiler-core@3.5.13)(vue@3.5.13(typescript@5.8.3))':
optionalDependencies:
'@vue/compiler-core': 3.5.13
vue: 3.5.13(typescript@5.8.3)
'@formatjs/ecma402-abstract@1.18.3':
dependencies:
'@formatjs/intl-localematcher': 0.5.4
@@ -9562,18 +9547,6 @@ snapshots:
optionalDependencies:
typescript: 5.5.4
'@formatjs/intl@2.10.4(typescript@5.8.3)':
dependencies:
'@formatjs/ecma402-abstract': 2.0.0
'@formatjs/fast-memoize': 2.2.0
'@formatjs/icu-messageformat-parser': 2.7.8
'@formatjs/intl-displaynames': 6.6.8
'@formatjs/intl-listformat': 7.5.7
intl-messageformat: 10.5.14
tslib: 2.6.3
optionalDependencies:
typescript: 5.8.3
'@formatjs/ts-transformer@3.13.14':
dependencies:
'@formatjs/icu-messageformat-parser': 2.7.8
@@ -11261,17 +11234,6 @@ snapshots:
transitivePeerDependencies:
- typescript
'@vintl/vintl@4.4.1(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3))':
dependencies:
'@braw/async-computed': 5.0.2(vue@3.5.13(typescript@5.8.3))
'@formatjs/icu-messageformat-parser': 2.7.8
'@formatjs/intl': 2.10.4(typescript@5.8.3)
'@formatjs/intl-localematcher': 0.4.2
intl-messageformat: 10.5.14
vue: 3.5.13(typescript@5.8.3)
transitivePeerDependencies:
- typescript
'@vitejs/plugin-vue-jsx@4.1.1(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.43.1))(vue@3.5.13(typescript@5.5.4))':
dependencies:
'@babel/core': 7.26.0
@@ -12077,7 +12039,7 @@ snapshots:
browserslist@4.25.1:
dependencies:
caniuse-lite: 1.0.30001727
electron-to-chromium: 1.5.182
electron-to-chromium: 1.5.191
node-releases: 2.0.19
update-browserslist-db: 1.1.3(browserslist@4.25.1)
optional: true
@@ -12655,7 +12617,7 @@ snapshots:
ee-first@1.1.1: {}
electron-to-chromium@1.5.182:
electron-to-chromium@1.5.191:
optional: true
electron-to-chromium@1.5.71: {}
@@ -17414,7 +17376,7 @@ snapshots:
magic-string: 0.30.17
mlly: 1.7.4
pathe: 2.0.3
picomatch: 4.0.2
picomatch: 4.0.3
pkg-types: 2.2.0
scule: 1.3.0
strip-literal: 3.0.0