Merge tag 'v0.10.27' into beta

This commit is contained in:
2026-01-27 23:03:46 +03:00
804 changed files with 69201 additions and 21982 deletions

View File

@@ -32,7 +32,7 @@ const client = new GenericModrinthClient({
// Explicitly make a request using client.request
const project: any = await client.request('/project/sodium', { api: 'labrinth', version: 2 })
// Example for archon (Modrinth Servers)
// Example for archon (Modrinth Hosting)
const servers = await client.request('/servers?limit=10', { api: 'archon', version: 0 })
// Or use the provided wrappers for better type support.

View File

@@ -2,15 +2,17 @@ import type { InferredClientModules } from '../modules'
import { buildModuleStructure } from '../modules'
import type { ClientConfig } from '../types/client'
import type { RequestContext, RequestOptions } from '../types/request'
import type { UploadMetadata, UploadProgress, UploadRequestOptions } from '../types/upload'
import type { AbstractFeature } from './abstract-feature'
import type { AbstractModule } from './abstract-module'
import { AbstractUploadClient } from './abstract-upload-client'
import type { AbstractWebSocketClient } from './abstract-websocket'
import { ModrinthApiError, ModrinthServerError } from './errors'
/**
* Abstract base client for Modrinth APIs
*/
export abstract class AbstractModrinthClient {
export abstract class AbstractModrinthClient extends AbstractUploadClient {
protected config: ClientConfig
protected features: AbstractFeature[]
@@ -30,6 +32,7 @@ export abstract class AbstractModrinthClient {
public readonly iso3166!: InferredClientModules['iso3166']
constructor(config: ClientConfig) {
super()
this.config = {
timeout: 10000,
labrinthBaseUrl: 'https://api.modrinth.com',
@@ -176,6 +179,35 @@ export abstract class AbstractModrinthClient {
return next()
}
/**
* Execute the feature chain for an upload
*
* Similar to executeFeatureChain but calls executeXHRUpload at the end.
* This allows features (auth, retry, etc.) to wrap the upload execution.
*/
protected async executeUploadFeatureChain<T>(
context: RequestContext,
progressCallbacks: Array<(p: UploadProgress) => void>,
abortController: AbortController,
): Promise<T> {
const applicableFeatures = this.features.filter((feature) => feature.shouldApply(context))
let index = applicableFeatures.length
const next = async (): Promise<T> => {
index--
if (index >= 0) {
return applicableFeatures[index].execute(next, context)
} else {
await this.config.hooks?.onRequest?.(context)
return this.executeXHRUpload<T>(context, progressCallbacks, abortController)
}
}
return next()
}
/**
* Build the full URL for a request
*/
@@ -212,6 +244,52 @@ export abstract class AbstractModrinthClient {
}
}
/**
* Build context for an upload request
*
* Sets metadata.isUpload = true so features can detect uploads.
* Supports both single file uploads and FormData uploads.
*/
protected buildUploadContext(
url: string,
path: string,
options: UploadRequestOptions,
): RequestContext {
let metadata: UploadMetadata
let body: File | Blob | FormData
if ('formData' in options && options.formData) {
metadata = {
isUpload: true,
formData: options.formData,
onProgress: options.onProgress,
}
body = options.formData
} else if ('file' in options && options.file) {
metadata = {
isUpload: true,
file: options.file,
onProgress: options.onProgress,
}
body = options.file
} else {
throw new Error('Upload options must include either file or formData')
}
return {
url,
path,
options: {
...options,
method: 'POST',
body,
},
attempt: 1,
startTime: Date.now(),
metadata,
}
}
/**
* Build default headers for all requests
*
@@ -243,6 +321,23 @@ export abstract class AbstractModrinthClient {
*/
protected abstract executeRequest<T>(url: string, options: RequestOptions): Promise<T>
/**
* Execute the actual XHR upload
*
* This must be implemented by platform clients that support uploads.
* Called at the end of the upload feature chain.
*
* @param context - Request context with upload metadata
* @param progressCallbacks - Callbacks to invoke on progress events
* @param abortController - Controller for cancellation
* @returns Promise resolving to the response data
*/
protected abstract executeXHRUpload<T>(
context: RequestContext,
progressCallbacks: Array<(p: UploadProgress) => void>,
abortController: AbortController,
): Promise<T>
/**
* Normalize an error into a ModrinthApiError
*

View File

@@ -0,0 +1,21 @@
import type { UploadHandle, UploadRequestOptions } from '../types/upload'
/**
* Abstract base class defining upload capability
*
* All clients that support file uploads must extend this class.
* Platform-specific implementations should provide the actual upload mechanism
* (e.g., XHR for browser environments).
*
* Upload goes through the feature chain (auth, retry, circuit-breaker, etc.)
* just like regular requests.
*/
export abstract class AbstractUploadClient {
/**
* Upload a file or FormData with progress tracking
* @param path - API path (e.g., '/fs/create')
* @param options - Upload options including file or formData, api, version
* @returns UploadHandle with promise, onProgress chain, and cancel method
*/
abstract upload<T = void>(path: string, options: UploadRequestOptions): UploadHandle<T>
}

View File

@@ -45,7 +45,7 @@ export interface AuthConfig extends FeatureConfig {
* ```
*/
export class AuthFeature extends AbstractFeature {
protected declare config: AuthConfig
declare protected config: AuthConfig
async execute<T>(next: () => Promise<T>, context: RequestContext): Promise<T> {
const token = await this.getToken()

View File

@@ -111,7 +111,7 @@ export class InMemoryCircuitBreakerStorage implements CircuitBreakerStorage {
* ```
*/
export class CircuitBreakerFeature extends AbstractFeature {
protected declare config: Required<CircuitBreakerConfig>
declare protected config: Required<CircuitBreakerConfig>
private storage: CircuitBreakerStorage
constructor(config?: CircuitBreakerConfig) {

View File

@@ -0,0 +1,149 @@
import { AbstractFeature, type FeatureConfig } from '../core/abstract-feature'
import { ModrinthApiError } from '../core/errors'
import type { RequestContext } from '../types/request'
/**
* Node authentication credentials
*/
export interface NodeAuth {
/** Node instance URL (e.g., "node-xyz.modrinth.com/modrinth/v0/fs") */
url: string
/** JWT token */
token: string
}
export interface NodeAuthConfig extends FeatureConfig {
/**
* Get current node auth. Returns null if not authenticated.
*/
getAuth: () => NodeAuth | null
/**
* Refresh the node authentication token.
*/
refreshAuth: () => Promise<void>
}
/**
* Handles authentication for Kyros node fs requests:
* - Automatically injects Authorization header
* - Builds the correct URL from node instance
* - Handles 401 errors by refreshing and retrying (max 3 times)
*
* Only applies to requests with `useNodeAuth: true` in options.
*
* @example
* ```typescript
* const nodeAuth = new NodeAuthFeature({
* getAuth: () => nodeAuthState.getAuth?.() ?? null,
* refreshAuth: async () => {
* if (nodeAuthState.refreshAuth) {
* await nodeAuthState.refreshAuth()
* }
* },
* })
* client.addFeature(nodeAuth)
* ```
*/
export class NodeAuthFeature extends AbstractFeature {
declare protected config: NodeAuthConfig
private refreshPromise: Promise<void> | null = null
shouldApply(context: RequestContext): boolean {
return context.options.useNodeAuth === true && this.config.enabled !== false
}
private async refreshAuthWithLock(): Promise<void> {
if (this.refreshPromise) {
return this.refreshPromise
}
this.refreshPromise = this.config.refreshAuth().finally(() => {
this.refreshPromise = null
})
return this.refreshPromise
}
async execute<T>(next: () => Promise<T>, context: RequestContext): Promise<T> {
const maxRetries = 3
let retryCount = 0
let auth = this.config.getAuth()
if (!auth || this.isTokenExpired(auth.token)) {
await this.refreshAuthWithLock()
auth = this.config.getAuth()
}
if (!auth) {
throw new Error('Failed to obtain node authentication')
}
this.applyAuth(context, auth)
while (true) {
try {
return await next()
} catch (error) {
if (error instanceof ModrinthApiError && error.statusCode === 401) {
retryCount++
if (retryCount >= maxRetries) {
throw new Error(
`Node authentication failed after ${maxRetries} retries. Please re-authenticate.`,
)
}
await this.refreshAuthWithLock()
auth = this.config.getAuth()
if (!auth) {
throw new Error('Failed to refresh node authentication')
}
this.applyAuth(context, auth)
continue
}
throw error
}
}
}
private applyAuth(context: RequestContext, auth: NodeAuth): void {
const baseUrl = `https://${auth.url.replace('v0/fs', '')}`
context.url = this.buildUrl(context.path, baseUrl, context.options.version)
context.options.headers = {
...context.options.headers,
Authorization: `Bearer ${auth.token}`,
}
context.options.skipAuth = true
}
private buildUrl(path: string, baseUrl: string, version: number | 'internal' | string): string {
const base = baseUrl.replace(/\/$/, '')
let versionPath = ''
if (version === 'internal') {
versionPath = '/_internal'
} else if (typeof version === 'number') {
versionPath = `/v${version}`
} else if (typeof version === 'string') {
versionPath = `/${version}`
}
const cleanPath = path.startsWith('/') ? path : `/${path}`
return `${base}${versionPath}${cleanPath}`
}
/**
* Check if a JWT token is expired or about to expire
* Refreshes proactively if expiring within next 10 seconds
*/
private isTokenExpired(token: string): boolean {
try {
const payload = JSON.parse(atob(token.split('.')[1]))
if (!payload.exp) return false
// refresh if expiring within 10 seconds
const expiresAt = payload.exp * 1000
return Date.now() >= expiresAt - 10000
} catch {
// cant decode, assume valid and let server decide
return false
}
}
}

View File

@@ -70,7 +70,7 @@ export interface RetryConfig extends FeatureConfig {
* ```
*/
export class RetryFeature extends AbstractFeature {
protected declare config: Required<RetryConfig>
declare protected config: Required<RetryConfig>
constructor(config?: RetryConfig) {
super(config)

View File

@@ -1,5 +1,6 @@
export { AbstractModrinthClient } from './core/abstract-client'
export { AbstractFeature, type FeatureConfig } from './core/abstract-feature'
export { AbstractUploadClient } from './core/abstract-upload-client'
export {
AbstractWebSocketClient,
type WebSocketConnection,
@@ -15,6 +16,7 @@ export {
type CircuitBreakerStorage,
InMemoryCircuitBreakerStorage,
} from './features/circuit-breaker'
export { type NodeAuth, type NodeAuthConfig, NodeAuthFeature } from './features/node-auth'
export { PANEL_VERSION, PanelVersionFeature } from './features/panel-version'
export { type BackoffStrategy, type RetryConfig, RetryFeature } from './features/retry'
export { type VerboseLoggingConfig, VerboseLoggingFeature } from './features/verbose-logging'
@@ -25,4 +27,7 @@ export type { NuxtClientConfig } from './platform/nuxt'
export { NuxtCircuitBreakerStorage, NuxtModrinthClient } from './platform/nuxt'
export type { TauriClientConfig } from './platform/tauri'
export { TauriModrinthClient } from './platform/tauri'
export { XHRUploadClient } from './platform/xhr-upload-client'
export { clearNodeAuthState, nodeAuthState, setNodeAuthState } from './state/node-auth'
export * from './types'
export { withJWTRetry } from './utils/jwt-retry'

View File

@@ -52,24 +52,6 @@ export class ArchonBackupsV0Module extends AbstractModule {
})
}
/** POST /modrinth/v0/servers/:server_id/backups/:backup_id/lock */
public async lock(serverId: string, backupId: string): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/backups/${backupId}/lock`, {
api: 'archon',
version: 'modrinth/v0',
method: 'POST',
})
}
/** POST /modrinth/v0/servers/:server_id/backups/:backup_id/unlock */
public async unlock(serverId: string, backupId: string): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/backups/${backupId}/unlock`, {
api: 'archon',
version: 'modrinth/v0',
method: 'POST',
})
}
/** POST /modrinth/v0/servers/:server_id/backups/:backup_id/retry */
public async retry(serverId: string, backupId: string): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/backups/${backupId}/retry`, {

View File

@@ -76,32 +76,6 @@ export class ArchonBackupsV1Module extends AbstractModule {
})
}
/** POST /v1/:server_id/worlds/:world_id/backups/:backup_id/lock */
public async lock(
serverId: string,
backupId: string,
worldId: string = DEFAULT_WORLD_ID,
): Promise<void> {
await this.client.request<void>(`/${serverId}/worlds/${worldId}/backups/${backupId}/lock`, {
api: 'archon',
version: 1,
method: 'POST',
})
}
/** POST /v1/:server_id/worlds/:world_id/backups/:backup_id/unlock */
public async unlock(
serverId: string,
backupId: string,
worldId: string = DEFAULT_WORLD_ID,
): Promise<void> {
await this.client.request<void>(`/${serverId}/worlds/${worldId}/backups/${backupId}/unlock`, {
api: 'archon',
version: 1,
method: 'POST',
})
}
/** POST /v1/:server_id/worlds/:world_id/backups/:backup_id/retry */
public async retry(
serverId: string,

View File

@@ -0,0 +1,56 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Archon } from '../types'
export class ArchonContentV0Module extends AbstractModule {
public getModuleID(): string {
return 'archon_content_v0'
}
/** GET /modrinth/v0/servers/:server_id/mods */
public async list(serverId: string): Promise<Archon.Content.v0.Mod[]> {
return this.client.request<Archon.Content.v0.Mod[]>(`/servers/${serverId}/mods`, {
api: 'archon',
version: 'modrinth/v0',
method: 'GET',
})
}
/** POST /modrinth/v0/servers/:server_id/mods */
public async install(
serverId: string,
request: Archon.Content.v0.InstallModRequest,
): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/mods`, {
api: 'archon',
version: 'modrinth/v0',
method: 'POST',
body: request,
})
}
/** POST /modrinth/v0/servers/:server_id/deleteMod */
public async delete(
serverId: string,
request: Archon.Content.v0.DeleteModRequest,
): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/deleteMod`, {
api: 'archon',
version: 'modrinth/v0',
method: 'POST',
body: request,
})
}
/** POST /modrinth/v0/servers/:server_id/mods/update */
public async update(
serverId: string,
request: Archon.Content.v0.UpdateModRequest,
): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/mods/update`, {
api: 'archon',
version: 'modrinth/v0',
method: 'POST',
body: request,
})
}
}

View File

@@ -1,5 +1,6 @@
export * from './backups/v0'
export * from './backups/v1'
export * from './content/v0'
export * from './servers/v0'
export * from './servers/v1'
export * from './types'

View File

@@ -78,4 +78,20 @@ export class ArchonServersV0Module extends AbstractModule {
method: 'GET',
})
}
/**
* Send a power action to a server (Start, Stop, Restart, Kill)
* POST /modrinth/v0/servers/:id/power
*/
public async power(
serverId: string,
action: 'Start' | 'Stop' | 'Restart' | 'Kill',
): Promise<void> {
await this.client.request(`/servers/${serverId}/power`, {
api: 'archon',
method: 'POST',
version: 'modrinth/v0',
body: { action },
})
}
}

View File

@@ -1,4 +1,40 @@
export namespace Archon {
export namespace Content {
export namespace v0 {
export type ContentKind = 'mod' | 'plugin'
export type Mod = {
filename: string
project_id: string | undefined
version_id: string | undefined
name: string | undefined
version_number: string | undefined
icon_url: string | undefined
owner: string | undefined
disabled: boolean
installing: boolean
}
export type InstallModRequest = {
rinth_ids: {
project_id: string
version_id: string
}
install_as: ContentKind
}
export type DeleteModRequest = {
path: string
}
export type UpdateModRequest = {
replace: string
project_id: string
version_id: string
}
}
}
export namespace Servers {
export namespace v0 {
export type ServerGetResponse = {
@@ -140,7 +176,6 @@ export namespace Archon {
id: string
name: string
created_at: string
locked: boolean
automated: boolean
interrupted: boolean
ongoing: boolean
@@ -274,6 +309,11 @@ export namespace Archon {
started: string
}
export type QueuedFilesystemOp = {
op: FilesystemOpKind
src: string
}
export type WSFilesystemOpsEvent = {
event: 'filesystem-ops'
all: FilesystemOperation[]

View File

@@ -2,6 +2,7 @@ import type { AbstractModrinthClient } from '../core/abstract-client'
import type { AbstractModule } from '../core/abstract-module'
import { ArchonBackupsV0Module } from './archon/backups/v0'
import { ArchonBackupsV1Module } from './archon/backups/v1'
import { ArchonContentV0Module } from './archon/content/v0'
import { ArchonServersV0Module } from './archon/servers/v0'
import { ArchonServersV1Module } from './archon/servers/v1'
import { ISO3166Module } from './iso3166'
@@ -28,6 +29,7 @@ type ModuleConstructor = new (client: AbstractModrinthClient) => AbstractModule
export const MODULE_REGISTRY = {
archon_backups_v0: ArchonBackupsV0Module,
archon_backups_v1: ArchonBackupsV1Module,
archon_content_v0: ArchonContentV0Module,
archon_servers_v0: ArchonServersV0Module,
archon_servers_v1: ArchonServersV1Module,
iso3166_data: ISO3166Module,

View File

@@ -1,4 +1,6 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { UploadHandle, UploadProgress } from '../../../types/upload'
import type { Kyros } from '../types'
export class KyrosFilesV0Module extends AbstractModule {
public getModuleID(): string {
@@ -6,47 +8,189 @@ export class KyrosFilesV0Module extends AbstractModule {
}
/**
* Download a file from a server's filesystem
* List directory contents with pagination
*
* @param nodeInstance - Node instance URL (e.g., "node-xyz.modrinth.com/modrinth/v0/fs")
* @param nodeToken - JWT token from getFilesystemAuth
* @param path - File path (e.g., "/server-icon-original.png")
* @returns Promise resolving to file Blob
* @param path - Directory path (e.g., "/")
* @param page - Page number (1-indexed)
* @param pageSize - Items per page
* @returns Directory listing with items and pagination info
*/
public async downloadFile(nodeInstance: string, nodeToken: string, path: string): Promise<Blob> {
return this.client.request<Blob>(`/fs/download`, {
api: `https://${nodeInstance.replace('v0/fs', '')}`,
method: 'GET',
public async listDirectory(
path: string,
page: number = 1,
pageSize: number = 100,
): Promise<Kyros.Files.v0.DirectoryResponse> {
return this.client.request<Kyros.Files.v0.DirectoryResponse>('/fs/list', {
api: '',
version: 'v0',
params: { path },
headers: { Authorization: `Bearer ${nodeToken}` },
method: 'GET',
params: { path, page, page_size: pageSize },
useNodeAuth: true,
})
}
/**
* Upload a file to a server's filesystem
* Create a file or directory
*
* @param path - Path for new item (e.g., "/new-folder")
* @param type - Type of item to create
*/
public async createFileOrFolder(path: string, type: 'file' | 'directory'): Promise<void> {
return this.client.request<void>('/fs/create', {
api: '',
version: 'v0',
method: 'POST',
params: { path, type },
headers: { 'Content-Type': 'application/octet-stream' },
useNodeAuth: true,
})
}
/**
* Download a file from a server's filesystem
*
* @param path - File path (e.g., "/server-icon-original.png")
* @returns Promise resolving to file Blob
*/
public async downloadFile(path: string): Promise<Blob> {
return this.client.request<Blob>('/fs/download', {
api: '',
version: 'v0',
method: 'GET',
params: { path },
useNodeAuth: true,
})
}
/**
* Upload a file to a server's filesystem with progress tracking
*
* @param nodeInstance - Node instance URL
* @param nodeToken - JWT token from getFilesystemAuth
* @param path - Destination path (e.g., "/server-icon.png")
* @param file - File to upload
* @param options - Optional progress callback and feature overrides
* @returns UploadHandle with promise, onProgress, and cancel
*/
public async uploadFile(
nodeInstance: string,
nodeToken: string,
public uploadFile(
path: string,
file: File,
): Promise<void> {
return this.client.request<void>(`/fs/create`, {
api: `https://${nodeInstance.replace('v0/fs', '')}`,
method: 'POST',
file: File | Blob,
options?: {
onProgress?: (progress: UploadProgress) => void
retry?: boolean | number
},
): UploadHandle<void> {
return this.client.upload<void>('/fs/create', {
api: '',
version: 'v0',
file,
params: { path, type: 'file' },
headers: {
Authorization: `Bearer ${nodeToken}`,
'Content-Type': 'application/octet-stream',
},
body: file,
onProgress: options?.onProgress,
retry: options?.retry,
useNodeAuth: true,
})
}
/**
* Update file contents
*
* @param path - File path to update
* @param content - New file content (string or Blob)
*/
public async updateFile(path: string, content: string | Blob): Promise<void> {
const blob = typeof content === 'string' ? new Blob([content]) : content
return this.client.request<void>('/fs/update', {
api: '',
version: 'v0',
method: 'PUT',
params: { path },
body: blob,
headers: { 'Content-Type': 'application/octet-stream' },
useNodeAuth: true,
})
}
/**
* Move a file or folder to a new location
*
* @param sourcePath - Current path
* @param destPath - New path
*/
public async moveFileOrFolder(sourcePath: string, destPath: string): Promise<void> {
return this.client.request<void>('/fs/move', {
api: '',
version: 'v0',
method: 'POST',
body: { source: sourcePath, destination: destPath },
useNodeAuth: true,
})
}
/**
* Rename a file or folder (convenience wrapper around move)
*
* @param path - Current file/folder path
* @param newName - New name (not full path)
*/
public async renameFileOrFolder(path: string, newName: string): Promise<void> {
const newPath = path.split('/').slice(0, -1).join('/') + '/' + newName
return this.moveFileOrFolder(path, newPath)
}
/**
* Delete a file or folder
*
* @param path - Path to delete
* @param recursive - If true, delete directory contents recursively
*/
public async deleteFileOrFolder(path: string, recursive: boolean): Promise<void> {
return this.client.request<void>('/fs/delete', {
api: '',
version: 'v0',
method: 'DELETE',
params: { path, recursive },
useNodeAuth: true,
})
}
/**
* Extract an archive file (zip, tar, etc.)
*
* Uses v1 API endpoint.
*
* @param path - Path to archive file
* @param override - If true, overwrite existing files
* @param dry - If true, perform dry run (returns conflicts without extracting)
* @returns Extract result with modpack name and conflicting files
*/
public async extractFile(
path: string,
override: boolean = true,
dry: boolean = false,
): Promise<Kyros.Files.v0.ExtractResult> {
return this.client.request<Kyros.Files.v0.ExtractResult>('/fs/unarchive', {
api: '',
version: 'v1',
method: 'POST',
params: { src: path, trg: '/', override, dry },
useNodeAuth: true,
})
}
/**
* Modify a filesystem operation (dismiss or cancel)
*
* Uses v1 API endpoint.
*
* @param opId - Operation ID (UUID)
* @param action - Action to perform
*/
public async modifyOperation(opId: string, action: 'dismiss' | 'cancel'): Promise<void> {
return this.client.request<void>(`/fs/ops/${action}`, {
api: '',
version: 'v1',
method: 'POST',
params: { id: opId },
useNodeAuth: true,
})
}
}

View File

@@ -1,7 +1,27 @@
export namespace Kyros {
export namespace Files {
export namespace v0 {
// Empty for now
export interface DirectoryItem {
name: string
type: 'file' | 'directory' | 'symlink'
path: string
modified: number
created: number
size?: number
count?: number
target?: string
}
export interface DirectoryResponse {
items: DirectoryItem[]
total: number
current: number
}
export interface ExtractResult {
modpack_name: string | null
conflicting_files: string[]
}
}
}
}

View File

@@ -114,4 +114,25 @@ export class LabrinthProjectsV2Module extends AbstractModule {
method: 'DELETE',
})
}
/**
* Get dependencies for a project
*
* @param id - Project ID or slug
* @returns Promise resolving to dependency info (projects and versions)
*
* @example
* ```typescript
* const deps = await client.labrinth.projects_v2.getDependencies('sodium')
* console.log(deps.projects) // dependent projects
* console.log(deps.versions) // dependent versions
* ```
*/
public async getDependencies(id: string): Promise<Labrinth.Projects.v2.DependencyInfo> {
return this.client.request<Labrinth.Projects.v2.DependencyInfo>(`/project/${id}/dependencies`, {
api: 'labrinth',
version: 2,
method: 'GET',
})
}
}

View File

@@ -1,4 +1,5 @@
import { AbstractModule } from '../../../core/abstract-module'
import { ModrinthApiError } from '../../../core/errors'
import type { Labrinth } from '../types'
export class LabrinthProjectsV3Module extends AbstractModule {
@@ -67,4 +68,39 @@ export class LabrinthProjectsV3Module extends AbstractModule {
body: data,
})
}
/**
* Get the organization that owns a project
*
* @param id - Project ID or slug
* @returns Promise resolving to the organization data, or null if the project is not owned by an organization
*/
public async getOrganization(id: string): Promise<Labrinth.Projects.v3.Organization | null> {
try {
return await this.client.request<Labrinth.Projects.v3.Organization>(
`/project/${id}/organization`,
{ api: 'labrinth', version: 3, method: 'GET' },
)
} catch (error) {
// 404 means the project is not owned by an organization
if (error instanceof ModrinthApiError && error.statusCode === 404) {
return null
}
throw error
}
}
/**
* Get the team members of a project
*
* @param id - Project ID or slug
* @returns Promise resolving to an array of team members
*/
public async getMembers(id: string): Promise<Labrinth.Projects.v3.TeamMember[]> {
return this.client.request<Labrinth.Projects.v3.TeamMember[]>(`/project/${id}/members`, {
api: 'labrinth',
version: 3,
method: 'GET',
})
}
}

View File

@@ -20,9 +20,9 @@ export class LabrinthStateModule extends AbstractModule {
public async build(): Promise<Labrinth.State.GeneratedState> {
const errors: unknown[] = []
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleError = (err: any, defaultValue: any) => {
const handleError = (err: any, defaultValue: any, endpoint: string) => {
console.error('Error fetching state data:', err)
errors.push(err)
errors.push({ endpoint, error: err })
return defaultValue
}
@@ -40,6 +40,7 @@ export class LabrinthStateModule extends AbstractModule {
products,
muralBankDetails,
iso3166Data,
payoutMethods,
] = await Promise.all([
// Tag endpoints
this.client
@@ -48,31 +49,31 @@ export class LabrinthStateModule extends AbstractModule {
version: 2,
method: 'GET',
})
.catch((err) => handleError(err, [])),
.catch((err) => handleError(err, [], '/v2/tag/category')),
this.client
.request<Labrinth.Tags.v2.Loader[]>('/tag/loader', {
api: 'labrinth',
version: 2,
method: 'GET',
})
.catch((err) => handleError(err, [])),
.catch((err) => handleError(err, [], '/v2/tag/loader')),
this.client
.request<Labrinth.Tags.v2.GameVersion[]>('/tag/game_version', {
api: 'labrinth',
version: 2,
method: 'GET',
})
.catch((err) => handleError(err, [])),
.catch((err) => handleError(err, [], '/v2/tag/game_version')),
this.client
.request<Labrinth.Tags.v2.DonationPlatform[]>('/tag/donation_platform', {
api: 'labrinth',
version: 2,
method: 'GET',
})
.catch((err) => handleError(err, [])),
.catch((err) => handleError(err, [], '/v2/tag/donation_platform')),
this.client
.request<string[]>('/tag/report_type', { api: 'labrinth', version: 2, method: 'GET' })
.catch((err) => handleError(err, [])),
.catch((err) => handleError(err, [], '/v2/tag/report_type')),
// Homepage data
this.client
@@ -82,7 +83,7 @@ export class LabrinthStateModule extends AbstractModule {
method: 'GET',
params: { count: '60' },
})
.catch((err) => handleError(err, [])),
.catch((err) => handleError(err, [], '/v2/projects_random')),
this.client
.request<Labrinth.Search.v2.SearchResults>('/search', {
api: 'labrinth',
@@ -90,7 +91,7 @@ export class LabrinthStateModule extends AbstractModule {
method: 'GET',
params: { limit: '3', query: 'leave', index: 'relevance' },
})
.catch((err) => handleError(err, {} as Labrinth.Search.v2.SearchResults)),
.catch((err) => handleError(err, {} as Labrinth.Search.v2.SearchResults, '/v2/search')),
this.client
.request<Labrinth.Search.v2.SearchResults>('/search', {
api: 'labrinth',
@@ -98,24 +99,41 @@ export class LabrinthStateModule extends AbstractModule {
method: 'GET',
params: { limit: '3', query: '', index: 'updated' },
})
.catch((err) => handleError(err, {} as Labrinth.Search.v2.SearchResults)),
.catch((err) => handleError(err, {} as Labrinth.Search.v2.SearchResults, '/v2/search')),
// Internal billing/mural endpoints
this.client.labrinth.billing_internal.getProducts().catch((err) => handleError(err, [])),
this.client.labrinth.billing_internal
.getProducts()
.catch((err) => handleError(err, [], '/_internal/billing/products')),
this.client
.request<{ bankDetails: Record<string, { bankNames: string[] }> }>('/mural/bank-details', {
api: 'labrinth',
version: 'internal',
method: 'GET',
})
.catch((err) => handleError(err, null)),
.catch((err) => handleError(err, null, '/_internal/mural/bank-details')),
// ISO3166 country and subdivision data
this.client.iso3166.data
.build()
.catch((err) => handleError(err, { countries: [], subdivisions: {} })),
.catch((err) => handleError(err, { countries: [], subdivisions: {} }, 'iso3166/data')),
// Payout methods for tremendous ID mapping
this.client
.request<Labrinth.State.PayoutMethodInfo[]>('/payout/methods', {
api: 'labrinth',
version: 3,
method: 'GET',
})
.catch((err) => handleError(err, [], '/v3/payout/methods')),
])
const tremendousIdMap = Object.fromEntries(
(payoutMethods as Labrinth.State.PayoutMethodInfo[])
.filter((m) => m.type === 'tremendous')
.map((m) => [m.id, { name: m.name, image_url: m.image_logo_url }]),
)
return {
categories,
loaders,
@@ -127,6 +145,7 @@ export class LabrinthStateModule extends AbstractModule {
homePageNotifs,
products,
muralBankDetails: muralBankDetails?.bankDetails,
tremendousIdMap,
countries: iso3166Data.countries,
subdivisions: iso3166Data.subdivisions,
errors,

View File

@@ -121,4 +121,23 @@ export class LabrinthTechReviewInternalModule extends AbstractModule {
body: data,
})
}
/**
* Get the project report and thread for a specific project.
*
* @param projectId - The project ID
* @returns The project report (may be null if no reports exist) and the moderation thread
*/
public async getProjectReport(
projectId: string,
): Promise<Labrinth.TechReview.Internal.ProjectReportResponse> {
return this.client.request<Labrinth.TechReview.Internal.ProjectReportResponse>(
`/moderation/tech-review/project/${projectId}`,
{
api: 'labrinth',
version: 'internal',
method: 'GET',
},
)
}
}

View File

@@ -194,6 +194,7 @@ export namespace Labrinth {
slug: string
project_type: ProjectType
team: string
organization: string | null
title: string
description: string
body: string
@@ -271,6 +272,11 @@ export namespace Labrinth {
offset?: number
limit?: number
}
export interface DependencyInfo {
projects: Project[]
versions: Labrinth.Versions.v2.Version[]
}
}
export namespace v3 {
@@ -363,6 +369,41 @@ export namespace Labrinth {
environment?: Environment
[key: string]: unknown
}
export type Organization = {
id: string
slug: string
name: string
team_id: string
description: string
icon_url: string | null
color: number | null
members: TeamMember[]
}
export type OrganizationMember = {
team_id: string
user: Users.v3.User
role: string
is_owner: boolean
permissions: number
organization_permissions: number
accepted: boolean
payouts_split: number
ordering: number
}
export type TeamMember = {
team_id: string
user: Users.v3.User
role: string
is_owner: boolean
permissions: number | null
organization_permissions: number | null
accepted: boolean
payouts_split: number | null
ordering: number
}
}
}
@@ -382,8 +423,13 @@ export namespace Labrinth {
export type FileType = 'required-resource-pack' | 'optional-resource-pack' | 'unknown'
export type VersionFileHash = {
sha512: string
sha1: string
}
export type VersionFile = {
hashes: Record<string, string>
hashes: VersionFileHash
url: string
filename: string
primary: boolean
@@ -437,6 +483,8 @@ export namespace Labrinth {
export interface GetProjectVersionsParams {
game_versions?: string[]
loaders?: string[]
include_changelog?: boolean
apiVersion?: 2 | 3
}
export type VersionChannel = 'release' | 'beta' | 'alpha'
@@ -699,6 +747,13 @@ export namespace Labrinth {
}
export namespace State {
export interface PayoutMethodInfo {
id: string
type: string
name: string
image_logo_url: string | null
}
export interface GeneratedState {
categories: Tags.v2.Category[]
loaders: Tags.v2.Loader[]
@@ -711,6 +766,13 @@ export namespace Labrinth {
bankNames: string[]
}
>
tremendousIdMap?: Record<
string,
{
name: string
image_url: string | null
}
>
homePageProjects?: Projects.v2.Project[]
homePageSearch?: Search.v2.SearchResults
@@ -735,6 +797,9 @@ export namespace Labrinth {
export type SearchProjectsFilter = {
project_type?: string[]
replied_to?: 'replied' | 'unreplied'
project_status?: string[]
issue_type?: string[]
}
export type SearchProjectsSort =
@@ -898,6 +963,11 @@ export namespace Labrinth {
export type DelphiSeverity = 'low' | 'medium' | 'high' | 'severe'
export type DelphiReportIssueStatus = 'pending' | 'safe' | 'unsafe'
export type ProjectReportResponse = {
project_report: ProjectReport | null
thread: Thread
}
}
}
}

View File

@@ -1,4 +1,5 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { UploadHandle } from '../../../types/upload'
import type { Labrinth } from '../types'
export class LabrinthVersionsV3Module extends AbstractModule {
@@ -27,17 +28,20 @@ export class LabrinthVersionsV3Module extends AbstractModule {
id: string,
options?: Labrinth.Versions.v3.GetProjectVersionsParams,
): Promise<Labrinth.Versions.v3.Version[]> {
const params: Record<string, string> = {}
const params: Record<string, string | boolean> = {}
if (options?.game_versions?.length) {
params.game_versions = JSON.stringify(options.game_versions)
}
if (options?.loaders?.length) {
params.loaders = JSON.stringify(options.loaders)
}
if (options?.include_changelog !== undefined) {
params.include_changelog = options.include_changelog
}
return this.client.request<Labrinth.Versions.v3.Version[]>(`/project/${id}/version`, {
api: 'labrinth',
version: 2, // TODO: move this to a versions v2 module to keep api-client clean and organized
version: options?.apiVersion ?? 2,
method: 'GET',
params: Object.keys(params).length > 0 ? params : undefined,
})
@@ -136,11 +140,11 @@ export class LabrinthVersionsV3Module extends AbstractModule {
* ```
*/
public async createVersion(
public createVersion(
draftVersion: Labrinth.Versions.v3.DraftVersion,
versionFiles: Labrinth.Versions.v3.DraftVersionFile[],
projectType: Labrinth.Projects.v2.ProjectType | null = null,
): Promise<Labrinth.Versions.v3.Version> {
): UploadHandle<Labrinth.Versions.v3.Version> {
const formData = new FormData()
const files = versionFiles.map((vf) => vf.file)
@@ -182,21 +186,15 @@ export class LabrinthVersionsV3Module extends AbstractModule {
formData.append('data', JSON.stringify(data))
files.forEach((file, i) => {
formData.append(fileParts[i], new Blob([file]), file.name)
formData.append(fileParts[i], file, file.name)
})
const newVersion = await this.client.request<Labrinth.Versions.v3.Version>(`/version`, {
return this.client.upload<Labrinth.Versions.v3.Version>(`/version`, {
api: 'labrinth',
version: 3,
method: 'POST',
body: formData,
formData,
timeout: 60 * 5 * 1000,
headers: {
'Content-Type': '',
},
})
return newVersion
}
/**
@@ -251,10 +249,10 @@ export class LabrinthVersionsV3Module extends AbstractModule {
})
}
public async addFilesToVersion(
public addFilesToVersion(
versionId: string,
versionFiles: Labrinth.Versions.v3.DraftVersionFile[],
): Promise<Labrinth.Versions.v3.Version> {
): UploadHandle<Labrinth.Versions.v3.Version> {
const formData = new FormData()
const files = versionFiles.map((vf) => vf.file)
@@ -273,18 +271,14 @@ export class LabrinthVersionsV3Module extends AbstractModule {
formData.append('data', JSON.stringify({ file_types: fileTypeMap }))
files.forEach((file, i) => {
formData.append(fileParts[i], new Blob([file]), file.name)
formData.append(fileParts[i], file, file.name)
})
return this.client.request<Labrinth.Versions.v3.Version>(`/version/${versionId}/file`, {
return this.client.upload<Labrinth.Versions.v3.Version>(`/version/${versionId}/file`, {
api: 'labrinth',
version: 2,
method: 'POST',
body: formData,
formData,
timeout: 60 * 5 * 1000,
headers: {
'Content-Type': '',
},
})
}
}

View File

@@ -1,10 +1,10 @@
import { $fetch, FetchError } from 'ofetch'
import { AbstractModrinthClient } from '../core/abstract-client'
import type { ModrinthApiError } from '../core/errors'
import type { ClientConfig } from '../types/client'
import type { RequestOptions } from '../types/request'
import { GenericWebSocketClient } from './websocket-generic'
import { XHRUploadClient } from './xhr-upload-client'
/**
* Generic platform client using ofetch
@@ -24,7 +24,7 @@ import { GenericWebSocketClient } from './websocket-generic'
* const project = await client.request('/project/sodium', { api: 'labrinth', version: 2 })
* ```
*/
export class GenericModrinthClient extends AbstractModrinthClient {
export class GenericModrinthClient extends XHRUploadClient {
constructor(config: ClientConfig) {
super(config)

View File

@@ -1,11 +1,12 @@
import { FetchError } from 'ofetch'
import { AbstractModrinthClient } from '../core/abstract-client'
import type { ModrinthApiError } from '../core/errors'
import { ModrinthApiError } from '../core/errors'
import type { CircuitBreakerState, CircuitBreakerStorage } from '../features/circuit-breaker'
import type { ClientConfig } from '../types/client'
import type { RequestOptions } from '../types/request'
import type { UploadHandle, UploadRequestOptions } from '../types/upload'
import { GenericWebSocketClient } from './websocket-generic'
import { XHRUploadClient } from './xhr-upload-client'
/**
* Circuit breaker storage using Nuxt's useState
@@ -42,10 +43,11 @@ export class NuxtCircuitBreakerStorage implements CircuitBreakerStorage {
export interface NuxtClientConfig extends ClientConfig {
// TODO: do we want to provide this for tauri+base as well? its not used on app
/**
* Rate limit key for server-side requests
* This is injected as x-ratelimit-key header on server-side
* Rate limit key for server-side requests.
* This is injected as x-ratelimit-key header on server-side.
* Can be a string (for env var) or async function (for CF Secrets Store).
*/
rateLimitKey?: string
rateLimitKey?: string | (() => Promise<string | undefined>)
}
/**
@@ -53,6 +55,8 @@ export interface NuxtClientConfig extends ClientConfig {
*
* This client is optimized for Nuxt applications and handles SSR/CSR automatically.
*
* Note: upload() is only available in browser context (CSR). It will throw during SSR.
*
* @example
* ```typescript
* // In a Nuxt composable
@@ -70,8 +74,10 @@ export interface NuxtClientConfig extends ClientConfig {
* const project = await client.request('/project/sodium', { api: 'labrinth', version: 2 })
* ```
*/
export class NuxtModrinthClient extends AbstractModrinthClient {
protected declare config: NuxtClientConfig
export class NuxtModrinthClient extends XHRUploadClient {
declare protected config: NuxtClientConfig
private rateLimitKeyResolved: string | undefined
private rateLimitKeyPromise: Promise<string | undefined> | undefined
constructor(config: NuxtClientConfig) {
super(config)
@@ -84,6 +90,54 @@ export class NuxtModrinthClient extends AbstractModrinthClient {
})
}
/**
* Resolve the rate limit key, handling both string and async function values.
* Results are cached for subsequent calls.
*/
private async resolveRateLimitKey(): Promise<string | undefined> {
if (this.rateLimitKeyResolved !== undefined) {
return this.rateLimitKeyResolved
}
const key = this.config.rateLimitKey
if (typeof key === 'string') {
this.rateLimitKeyResolved = key
} else if (typeof key === 'function') {
if (!this.rateLimitKeyPromise) {
this.rateLimitKeyPromise = key()
}
this.rateLimitKeyResolved = await this.rateLimitKeyPromise
}
return this.rateLimitKeyResolved
}
/**
* Override request to resolve rate limit key before calling super.
* This allows async fetching of the key from CF Secrets Store.
*/
async request<T>(path: string, options: RequestOptions): Promise<T> {
// @ts-expect-error - import.meta is provided by Nuxt
if (import.meta.server) {
await this.resolveRateLimitKey()
}
return super.request(path, options)
}
/**
* Upload a file with progress tracking
*
* Note: This method is only available in browser context (CSR).
* Calling during SSR will throw an error.
*/
upload<T = void>(path: string, options: UploadRequestOptions): UploadHandle<T> {
// @ts-expect-error - import.meta is provided by Nuxt
if (import.meta.server) {
throw new ModrinthApiError('upload() is not supported during SSR')
}
return super.upload(path, options)
}
protected async executeRequest<T>(url: string, options: RequestOptions): Promise<T> {
try {
// @ts-expect-error - $fetch is provided by Nuxt runtime
@@ -115,9 +169,10 @@ export class NuxtModrinthClient extends AbstractModrinthClient {
...super.buildDefaultHeaders(),
}
// Use the resolved key (populated by resolveRateLimitKey in request())
// @ts-expect-error - import.meta is provided by Nuxt
if (import.meta.server && this.config.rateLimitKey) {
headers['x-ratelimit-key'] = this.config.rateLimitKey
if (import.meta.server && this.rateLimitKeyResolved) {
headers['x-ratelimit-key'] = this.rateLimitKeyResolved
}
return headers

View File

@@ -1,8 +1,8 @@
import { AbstractModrinthClient } from '../core/abstract-client'
import type { ModrinthApiError } from '../core/errors'
import type { ClientConfig } from '../types/client'
import type { RequestOptions } from '../types/request'
import { GenericWebSocketClient } from './websocket-generic'
import { XHRUploadClient } from './xhr-upload-client'
/**
* Tauri-specific configuration
@@ -20,7 +20,9 @@ interface HttpError extends Error {
/**
* Tauri platform client using Tauri v2 HTTP plugin
*
* Extends XHRUploadClient to provide upload with progress tracking.
*
* @example
* ```typescript
* import { getVersion } from '@tauri-apps/api/app'
@@ -36,8 +38,8 @@ interface HttpError extends Error {
* const project = await client.request('/project/sodium', { api: 'labrinth', version: 2 })
* ```
*/
export class TauriModrinthClient extends AbstractModrinthClient {
protected declare config: TauriClientConfig
export class TauriModrinthClient extends XHRUploadClient {
declare protected config: TauriClientConfig
constructor(config: TauriClientConfig) {
super(config)

View File

@@ -0,0 +1,154 @@
import { AbstractModrinthClient } from '../core/abstract-client'
import { ModrinthApiError } from '../core/errors'
import type { RequestContext } from '../types/request'
import type {
UploadHandle,
UploadMetadata,
UploadProgress,
UploadRequestOptions,
} from '../types/upload'
/**
* Abstract client with XHR-based upload implementation
*
* Platform-specific clients should extend this instead of AbstractModrinthClient
* to inherit the XHR upload implementation.
*/
export abstract class XHRUploadClient extends AbstractModrinthClient {
upload<T = void>(path: string, options: UploadRequestOptions): UploadHandle<T> {
let baseUrl: string
if (options.api === 'labrinth') {
baseUrl = this.config.labrinthBaseUrl!
} else if (options.api === 'archon') {
baseUrl = this.config.archonBaseUrl!
} else {
baseUrl = options.api
}
const url = this.buildUrl(path, baseUrl, options.version)
// For FormData uploads, don't set Content-Type (let browser set multipart boundary)
// For file uploads, use application/octet-stream
const isFormData = 'formData' in options && options.formData instanceof FormData
const baseHeaders = this.buildDefaultHeaders()
// Remove Content-Type for FormData so browser can set multipart/form-data with boundary
if (isFormData) {
delete baseHeaders['Content-Type']
} else {
baseHeaders['Content-Type'] = 'application/octet-stream'
}
const mergedOptions: UploadRequestOptions = {
retry: false, // default: don't retry uploads
...options,
headers: {
...baseHeaders,
...options.headers,
},
}
const context = this.buildUploadContext(url, path, mergedOptions)
const progressCallbacks: Array<(p: UploadProgress) => void> = []
if (mergedOptions.onProgress) {
progressCallbacks.push(mergedOptions.onProgress)
}
const abortController = new AbortController()
if (mergedOptions.signal) {
mergedOptions.signal.addEventListener('abort', () => abortController.abort())
}
const handle: UploadHandle<T> = {
promise: this.executeUploadFeatureChain<T>(context, progressCallbacks, abortController)
.then(async (result) => {
await this.config.hooks?.onResponse?.(result, context)
return result
})
.catch(async (error) => {
const apiError = this.normalizeError(error, context)
await this.config.hooks?.onError?.(apiError, context)
throw apiError
}),
onProgress: (callback) => {
progressCallbacks.push(callback)
return handle
},
cancel: () => abortController.abort(),
}
return handle
}
protected executeXHRUpload<T>(
context: RequestContext,
progressCallbacks: Array<(p: UploadProgress) => void>,
abortController: AbortController,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const xhr = new XMLHttpRequest()
const metadata = context.metadata as UploadMetadata
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const progress: UploadProgress = {
loaded: e.loaded,
total: e.total,
progress: e.loaded / e.total,
}
progressCallbacks.forEach((cb) => cb(progress))
}
})
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(xhr.response ? JSON.parse(xhr.response) : (undefined as T))
} catch {
resolve(undefined as T)
}
} else {
reject(this.createUploadError(xhr))
}
})
xhr.addEventListener('error', () => reject(new ModrinthApiError('Upload failed')))
xhr.addEventListener('abort', () => reject(new ModrinthApiError('Upload cancelled')))
// build URL with params (unlike $fetch, XHR doesn't handle params automatically)
let url = context.url
if (context.options.params) {
const queryString = new URLSearchParams(
Object.entries(context.options.params).map(([k, v]) => [k, String(v)]),
).toString()
url += (url.includes('?') ? '&' : '?') + queryString
}
xhr.open('POST', url)
// apply headers from context (features may have modified them)
for (const [key, value] of Object.entries(context.options.headers ?? {})) {
xhr.setRequestHeader(key, value)
}
// Send either FormData or file depending on what was provided
const data = 'formData' in metadata ? metadata.formData : metadata.file
xhr.send(data)
abortController.signal.addEventListener('abort', () => xhr.abort())
})
}
protected createUploadError(xhr: XMLHttpRequest): ModrinthApiError {
let responseData: unknown
try {
responseData = xhr.response ? JSON.parse(xhr.response) : undefined
} catch {
responseData = xhr.responseText
}
return this.createNormalizedError(
new Error(`Upload failed with status ${xhr.status}`),
xhr.status,
responseData,
)
}
}

View File

@@ -0,0 +1,45 @@
import type { NodeAuth } from '../features/node-auth'
/**
* Global node auth state.
* Set by server management pages, read by NodeAuthFeature.
*/
export const nodeAuthState = {
getAuth: null as (() => NodeAuth | null) | null,
refreshAuth: null as (() => Promise<void>) | null,
}
/**
* Configure the node auth state. Call this when entering server management.
*
* @param getAuth - Function that returns current auth or null
* @param refreshAuth - Function to refresh the auth token
*
* @example
* ```typescript
* // In server management page setup
* setNodeAuthState(
* () => fsAuth.value,
* refreshFsAuth,
* )
* ```
*/
export function setNodeAuthState(getAuth: () => NodeAuth | null, refreshAuth: () => Promise<void>) {
nodeAuthState.getAuth = getAuth
nodeAuthState.refreshAuth = refreshAuth
}
/**
* Clear the node auth state. Call this when leaving server management.
*
* @example
* ```typescript
* onUnmounted(() => {
* clearNodeAuthState()
* })
* ```
*/
export function clearNodeAuthState() {
nodeAuthState.getAuth = null
nodeAuthState.refreshAuth = null
}

View File

@@ -39,7 +39,7 @@ export interface ClientConfig {
labrinthBaseUrl?: string
/**
* Base URL for Archon API (Modrinth Servers API)
* Base URL for Archon API (Modrinth Hosting API)
* @default 'https://archon.modrinth.com'
*/
archonBaseUrl?: string

View File

@@ -11,3 +11,4 @@ export type { ClientConfig, RequestHooks } from './client'
export type { ApiErrorData, ModrinthErrorResponse } from './errors'
export { isModrinthErrorResponse } from './errors'
export type { HttpMethod, RequestContext, RequestOptions, ResponseData } from './request'
export type { UploadHandle, UploadMetadata, UploadProgress, UploadRequestOptions } from './upload'

View File

@@ -10,7 +10,7 @@ export type RequestOptions = {
/**
* API to use for this request
* - 'labrinth': Main Modrinth API (resolves to labrinthBaseUrl)
* - 'archon': Modrinth Servers API (resolves to archonBaseUrl)
* - 'archon': Modrinth Hosting API (resolves to archonBaseUrl)
* - string: Custom base URL (e.g., 'https://custom-api.com')
*/
api: 'labrinth' | 'archon' | string
@@ -73,6 +73,13 @@ export type RequestOptions = {
* @default false
*/
skipAuth?: boolean
/**
* Use node authentication for this request.
* When true, NodeAuthFeature will handle auth injection and URL building.
* @default false
*/
useNodeAuth?: boolean
}
/**
@@ -106,6 +113,13 @@ export type RequestContext = {
/**
* Additional metadata that features can attach
*
* For uploads, this contains:
* - isUpload: true
* - file: File | Blob being uploaded
* - onProgress: progress callback (if provided)
*
* Features can check `context.metadata?.isUpload` to detect uploads.
*/
metadata?: Record<string, unknown>
}

View File

@@ -0,0 +1,88 @@
import type { RequestOptions } from './request'
/**
* Progress information for file uploads
*/
export interface UploadProgress {
/** Bytes uploaded so far */
loaded: number
/** Total bytes to upload */
total: number
/** Progress as a decimal (0-1) */
progress: number
}
/**
* Base options for upload requests
*/
interface BaseUploadRequestOptions extends Omit<RequestOptions, 'body' | 'method'> {
/** Callback for progress updates */
onProgress?: (progress: UploadProgress) => void
}
/**
* Options for single file upload requests
*/
export interface FileUploadRequestOptions extends BaseUploadRequestOptions {
/** File or Blob to upload */
file: File | Blob
formData?: never
}
/**
* Options for FormData upload requests
*
* Used for multipart uploads (e.g., version file uploads) that need
* to send metadata alongside files.
*/
export interface FormDataUploadRequestOptions extends BaseUploadRequestOptions {
/** FormData containing files and metadata */
formData: FormData
file?: never
}
/**
* Options for upload requests - either a single file or FormData
*/
export type UploadRequestOptions = FileUploadRequestOptions | FormDataUploadRequestOptions
/**
* Metadata attached to file upload contexts
*
* Features can check `context.metadata?.isUpload` to detect uploads.
*/
export interface FileUploadMetadata extends Record<string, unknown> {
isUpload: true
file: File | Blob
formData?: never
onProgress?: (progress: UploadProgress) => void
}
/**
* Metadata attached to FormData upload contexts
*/
export interface FormDataUploadMetadata extends Record<string, unknown> {
isUpload: true
formData: FormData
file?: never
onProgress?: (progress: UploadProgress) => void
}
/**
* Metadata attached to upload contexts - either file or FormData
*/
export type UploadMetadata = FileUploadMetadata | FormDataUploadMetadata
/**
* Handle returned from upload operations
*
* Provides the upload promise, progress subscription, and cancellation.
*/
export interface UploadHandle<T> {
/** Promise that resolves when upload completes */
promise: Promise<T>
/** Subscribe to progress updates (chainable) */
onProgress: (callback: (progress: UploadProgress) => void) => UploadHandle<T>
/** Cancel the upload */
cancel: () => void
}

View File

@@ -0,0 +1,20 @@
import { ModrinthApiError } from '../core/errors'
/**
* Wrap a function with JWT retry logic.
* On 401, calls refreshToken() and retries once.
*/
export async function withJWTRetry<T>(
fn: () => Promise<T>,
refreshToken: () => Promise<void>,
): Promise<T> {
try {
return await fn()
} catch (error) {
if (error instanceof ModrinthApiError && error.statusCode === 401) {
await refreshToken()
return await fn()
}
throw error
}
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n locale = $4,\n default_page = $5,\n collapsed_navigation = $6,\n advanced_rendering = $7,\n native_decorations = $8,\n\n discord_rpc = $9,\n developer_mode = $10,\n telemetry = $11,\n personalized_ads = $12,\n\n onboarded = $13,\n\n extra_launch_args = jsonb($14),\n custom_env_vars = jsonb($15),\n mc_memory_max = $16,\n mc_force_fullscreen = $17,\n mc_game_resolution_x = $18,\n mc_game_resolution_y = $19,\n hide_on_process_start = $20,\n\n hook_pre_launch = $21,\n hook_wrapper = $22,\n hook_post_exit = $23,\n\n custom_dir = $24,\n prev_custom_dir = $25,\n migrated = $26,\n\n toggle_sidebar = $27,\n feature_flags = $28,\n hide_nametag_skins_page = $29,\n\n skipped_update = $30,\n pending_update_toast_for_version = $31,\n auto_download_updates = $32,\n\n version = $33\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 33
},
"nullable": []
},
"hash": "175067f04e775f5469146f3cb77c422c3ab7203409083fd3c9c968b00b46918f"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n onboarded,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,\n skipped_update, pending_update_toast_for_version, auto_download_updates,\n version\n FROM settings\n ",
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, locale, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n onboarded,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,\n skipped_update, pending_update_toast_for_version, auto_download_updates,\n version\n FROM settings\n ",
"describe": {
"columns": [
{
@@ -19,148 +19,153 @@
"type_info": "Text"
},
{
"name": "default_page",
"name": "locale",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "collapsed_navigation",
"name": "default_page",
"ordinal": 4,
"type_info": "Integer"
"type_info": "Text"
},
{
"name": "hide_nametag_skins_page",
"name": "collapsed_navigation",
"ordinal": 5,
"type_info": "Integer"
},
{
"name": "advanced_rendering",
"name": "hide_nametag_skins_page",
"ordinal": 6,
"type_info": "Integer"
},
{
"name": "native_decorations",
"name": "advanced_rendering",
"ordinal": 7,
"type_info": "Integer"
},
{
"name": "discord_rpc",
"name": "native_decorations",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "developer_mode",
"name": "discord_rpc",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "telemetry",
"name": "developer_mode",
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "personalized_ads",
"name": "telemetry",
"ordinal": 11,
"type_info": "Integer"
},
{
"name": "onboarded",
"name": "personalized_ads",
"ordinal": 12,
"type_info": "Integer"
},
{
"name": "extra_launch_args",
"name": "onboarded",
"ordinal": 13,
"type_info": "Text"
"type_info": "Integer"
},
{
"name": "custom_env_vars",
"name": "extra_launch_args",
"ordinal": 14,
"type_info": "Text"
},
{
"name": "mc_memory_max",
"name": "custom_env_vars",
"ordinal": 15,
"type_info": "Integer"
"type_info": "Text"
},
{
"name": "mc_force_fullscreen",
"name": "mc_memory_max",
"ordinal": 16,
"type_info": "Integer"
},
{
"name": "mc_game_resolution_x",
"name": "mc_force_fullscreen",
"ordinal": 17,
"type_info": "Integer"
},
{
"name": "mc_game_resolution_y",
"name": "mc_game_resolution_x",
"ordinal": 18,
"type_info": "Integer"
},
{
"name": "hide_on_process_start",
"name": "mc_game_resolution_y",
"ordinal": 19,
"type_info": "Integer"
},
{
"name": "hook_pre_launch",
"name": "hide_on_process_start",
"ordinal": 20,
"type_info": "Text"
"type_info": "Integer"
},
{
"name": "hook_wrapper",
"name": "hook_pre_launch",
"ordinal": 21,
"type_info": "Text"
},
{
"name": "hook_post_exit",
"name": "hook_wrapper",
"ordinal": 22,
"type_info": "Text"
},
{
"name": "custom_dir",
"name": "hook_post_exit",
"ordinal": 23,
"type_info": "Text"
},
{
"name": "prev_custom_dir",
"name": "custom_dir",
"ordinal": 24,
"type_info": "Text"
},
{
"name": "migrated",
"name": "prev_custom_dir",
"ordinal": 25,
"type_info": "Text"
},
{
"name": "migrated",
"ordinal": 26,
"type_info": "Integer"
},
{
"name": "feature_flags",
"ordinal": 26,
"ordinal": 27,
"type_info": "Text"
},
{
"name": "toggle_sidebar",
"ordinal": 27,
"ordinal": 28,
"type_info": "Integer"
},
{
"name": "skipped_update",
"ordinal": 28,
"type_info": "Text"
},
{
"name": "pending_update_toast_for_version",
"ordinal": 29,
"type_info": "Text"
},
{
"name": "auto_download_updates",
"name": "pending_update_toast_for_version",
"ordinal": 30,
"type_info": "Text"
},
{
"name": "auto_download_updates",
"ordinal": 31,
"type_info": "Integer"
},
{
"name": "version",
"ordinal": 31,
"ordinal": 32,
"type_info": "Integer"
}
],
@@ -181,6 +186,7 @@
false,
false,
false,
false,
null,
null,
false,
@@ -202,5 +208,5 @@
false
]
},
"hash": "07ea3a644644de61c4ed7c30ee711d29fd49f10534230b1b03097275a30cb50f"
"hash": "8e62fba05f331f91822ec204695ecb40567541c547181a4ef847318845cf3110"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n default_page = $4,\n collapsed_navigation = $5,\n advanced_rendering = $6,\n native_decorations = $7,\n\n discord_rpc = $8,\n developer_mode = $9,\n telemetry = $10,\n personalized_ads = $11,\n\n onboarded = $12,\n\n extra_launch_args = jsonb($13),\n custom_env_vars = jsonb($14),\n mc_memory_max = $15,\n mc_force_fullscreen = $16,\n mc_game_resolution_x = $17,\n mc_game_resolution_y = $18,\n hide_on_process_start = $19,\n\n hook_pre_launch = $20,\n hook_wrapper = $21,\n hook_post_exit = $22,\n\n custom_dir = $23,\n prev_custom_dir = $24,\n migrated = $25,\n\n toggle_sidebar = $26,\n feature_flags = $27,\n hide_nametag_skins_page = $28,\n\n skipped_update = $29,\n pending_update_toast_for_version = $30,\n auto_download_updates = $31,\n\n version = $32\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 32
},
"nullable": []
},
"hash": "a40e60da6dd1312d4a1ed52fa8fd2394e7ad21de1cb44cf8b93c4b1459cdc716"
}

View File

@@ -0,0 +1,9 @@
# Copying
The source code of Modrinth App's backend is licensed under the GNU General Public License, Version 3 only, which is provided in the file [LICENSE](./LICENSE). However, some files listed below are licensed under a different license.
## Modrinth logo
The use of Modrinth branding elements, including but not limited to the wrench-in-labyrinth logo, the landing image, and any variations thereof, is strictly prohibited without explicit written permission from Rinth, Inc. This includes trademarks, logos, or other branding elements.
> All rights reserved. © 2020-2025 Rinth, Inc.

View File

@@ -10,7 +10,7 @@ async-compression = { workspace = true, features = ["gzip", "tokio"] }
async-recursion = { workspace = true }
async-tungstenite = { workspace = true, features = [
"tokio-runtime",
"tokio-rustls-webpki-roots",
"tokio-rustls-webpki-roots"
] }
async-walkdir = { workspace = true }
async_zip = { workspace = true, features = [
@@ -46,6 +46,7 @@ itertools = { workspace = true }
notify = { workspace = true }
notify-debouncer-mini = { workspace = true }
p256 = { workspace = true, features = ["ecdsa"] }
parking_lot = { workspace = true }
paste = { workspace = true }
path-util = { workspace = true }
phf = { workspace = true }
@@ -95,12 +96,7 @@ tokio = { workspace = true, features = [
"sync",
"time",
] }
tokio-util = { workspace = true, features = [
"compat",
"io",
"io-util",
"time",
] }
tokio-util = { workspace = true, features = ["compat", "io", "io-util", "time"] }
tracing = { workspace = true }
tracing-error = { workspace = true }
tracing-subscriber = { workspace = true, features = ["chrono", "env-filter"] }

View File

@@ -0,0 +1,2 @@
-- Add migration script here
ALTER TABLE settings ADD COLUMN locale TEXT NOT NULL DEFAULT 'en-US';

View File

@@ -65,6 +65,9 @@ pub enum ErrorKind {
#[error("Error fetching URL: {0}")]
FetchError(#[from] reqwest::Error),
#[error("Too many API errors; temporarily blocked")]
ApiIsDownError,
#[error("{0}")]
LabrinthError(LabrinthError),

View File

@@ -73,7 +73,7 @@ impl QuickPlayVersion {
let mut singleplayer = QuickPlaySingleplayerVersion::Builtin;
let mut singleplayer_version = singleplayer.min_version();
for version in versions.iter().take(version_index - 1) {
for version in versions.iter().take(version_index) {
if let Some(check_version) = server_version
&& version.id == check_version
{

View File

@@ -26,8 +26,13 @@ pub use event::{
pub use logger::start_logger;
pub use state::State;
pub const LAUNCHER_USER_AGENT: &str = concat!(
"modrinth/theseus/",
env!("CARGO_PKG_VERSION"),
" (support@modrinth.com)"
);
pub fn launcher_user_agent() -> String {
const LAUNCHER_BASE_USER_AGENT: &str =
concat!("modrinth/theseus/", env!("CARGO_PKG_VERSION"),);
format!(
"{} ({}; support@modrinth.com)",
LAUNCHER_BASE_USER_AGENT,
std::env::consts::OS
)
}

View File

@@ -1307,9 +1307,9 @@ impl CachedEntry {
let variations =
futures::future::try_join_all(filtered_keys.iter().map(
|((loaders_key, game_version), hashes)| {
fetch_json::<HashMap<String, Version>>(
fetch_json::<HashMap<String, Vec<Version>>>(
Method::POST,
concat!(env!("MODRINTH_API_URL"), "version_files/update"),
concat!(env!("MODRINTH_API_URL"), "version_files/update_many"),
None,
Some(serde_json::json!({
"algorithm": "sha1",
@@ -1330,28 +1330,30 @@ impl CachedEntry {
&filtered_keys[index];
for hash in hashes {
let version = variation.remove(hash);
let versions = variation.remove(hash);
if let Some(version) = version {
let version_id = version.id.clone();
vals.push((
CacheValue::Version(version).get_entry(),
false,
));
if let Some(versions) = versions {
for version in versions {
let version_id = version.id.clone();
vals.push((
CacheValue::Version(version).get_entry(),
false,
));
vals.push((
CacheValue::FileUpdate(CachedFileUpdate {
hash: hash.clone(),
game_version: game_version.clone(),
loaders: loaders_key
.split('+')
.map(|x| x.to_string())
.collect(),
update_version_id: version_id,
})
.get_entry(),
true,
));
vals.push((
CacheValue::FileUpdate(CachedFileUpdate {
hash: hash.clone(),
game_version: game_version.clone(),
loaders: loaders_key
.split('+')
.map(|x| x.to_string())
.collect(),
update_version_id: version_id,
})
.get_entry(),
true,
));
}
} else {
vals.push((
CacheValueType::FileUpdate.get_empty_entry(

View File

@@ -1,5 +1,4 @@
use crate::ErrorKind;
use crate::LAUNCHER_USER_AGENT;
use crate::data::ModrinthCredentials;
use crate::event::FriendPayload;
use crate::event::emit::emit_friend;
@@ -85,7 +84,7 @@ impl FriendsSocket {
request.headers_mut().insert(
"User-Agent",
HeaderValue::from_str(LAUNCHER_USER_AGENT).unwrap(),
HeaderValue::from_str(&crate::launcher_user_agent()).unwrap(),
);
let res = connect_async(request).await;

View File

@@ -1009,17 +1009,15 @@ impl Profile {
initial_file.file_name
);
let update_version_id = if let Some(update) = file_updates
.iter()
.find(|x| x.hash == hash.hash)
.map(|x| x.update_version_id.clone())
{
if let Some(metadata) = &file {
if metadata.version_id != update {
Some(update)
} else {
None
}
let update_version_id = if let Some(metadata) = &file {
let update_ids: Vec<String> = file_updates
.iter()
.filter(|x| x.hash == hash.hash)
.map(|x| x.update_version_id.clone())
.collect();
if !update_ids.contains(&metadata.version_id) {
update_ids.into_iter().next()
} else {
None
}

View File

@@ -12,6 +12,7 @@ pub struct Settings {
pub max_concurrent_writes: usize,
pub theme: Theme,
pub locale: String,
pub default_page: DefaultPage,
pub collapsed_navigation: bool,
pub hide_nametag_skins_page: bool,
@@ -66,7 +67,7 @@ impl Settings {
"
SELECT
max_concurrent_writes, max_concurrent_downloads,
theme, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,
theme, locale, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,
discord_rpc, developer_mode, telemetry, personalized_ads,
onboarded,
json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,
@@ -85,6 +86,7 @@ impl Settings {
max_concurrent_downloads: res.max_concurrent_downloads as usize,
max_concurrent_writes: res.max_concurrent_writes as usize,
theme: Theme::from_string(&res.theme),
locale: res.locale,
default_page: DefaultPage::from_string(&res.default_page),
collapsed_navigation: res.collapsed_navigation == 1,
hide_nametag_skins_page: res.hide_nametag_skins_page == 1,
@@ -157,47 +159,49 @@ impl Settings {
max_concurrent_downloads = $2,
theme = $3,
default_page = $4,
collapsed_navigation = $5,
advanced_rendering = $6,
native_decorations = $7,
locale = $4,
default_page = $5,
collapsed_navigation = $6,
advanced_rendering = $7,
native_decorations = $8,
discord_rpc = $8,
developer_mode = $9,
telemetry = $10,
personalized_ads = $11,
discord_rpc = $9,
developer_mode = $10,
telemetry = $11,
personalized_ads = $12,
onboarded = $12,
onboarded = $13,
extra_launch_args = jsonb($13),
custom_env_vars = jsonb($14),
mc_memory_max = $15,
mc_force_fullscreen = $16,
mc_game_resolution_x = $17,
mc_game_resolution_y = $18,
hide_on_process_start = $19,
extra_launch_args = jsonb($14),
custom_env_vars = jsonb($15),
mc_memory_max = $16,
mc_force_fullscreen = $17,
mc_game_resolution_x = $18,
mc_game_resolution_y = $19,
hide_on_process_start = $20,
hook_pre_launch = $20,
hook_wrapper = $21,
hook_post_exit = $22,
hook_pre_launch = $21,
hook_wrapper = $22,
hook_post_exit = $23,
custom_dir = $23,
prev_custom_dir = $24,
migrated = $25,
custom_dir = $24,
prev_custom_dir = $25,
migrated = $26,
toggle_sidebar = $26,
feature_flags = $27,
hide_nametag_skins_page = $28,
toggle_sidebar = $27,
feature_flags = $28,
hide_nametag_skins_page = $29,
skipped_update = $29,
pending_update_toast_for_version = $30,
auto_download_updates = $31,
skipped_update = $30,
pending_update_toast_for_version = $31,
auto_download_updates = $32,
version = $32
version = $33
",
max_concurrent_writes,
max_concurrent_downloads,
theme,
self.locale,
default_page,
self.collapsed_navigation,
self.advanced_rendering,

View File

@@ -1,12 +1,15 @@
//! Functions for fetching information from the Internet
use super::io::{self, IOError};
use crate::ErrorKind;
use crate::LAUNCHER_USER_AGENT;
use crate::event::LoadingBarId;
use crate::event::emit::emit_loading;
use bytes::Bytes;
use chrono::{DateTime, TimeDelta, Utc};
use parking_lot::Mutex;
use rand::Rng;
use reqwest::Method;
use serde::de::DeserializeOwned;
use std::collections::VecDeque;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
@@ -19,10 +22,120 @@ pub struct IoSemaphore(pub Semaphore);
#[derive(Debug)]
pub struct FetchSemaphore(pub Semaphore);
struct FetchFence {
inner: Mutex<FenceInner>,
}
impl FetchFence {
pub fn is_blocked(&self) -> bool {
self.inner.lock().is_blocked()
}
pub fn record_ok(&self) {
self.inner.lock().record_ok()
}
pub fn record_fail(&self) {
self.inner.lock().record_fail()
}
}
struct FenceInner {
failures: VecDeque<DateTime<Utc>>,
block_until: Option<DateTime<Utc>>,
block_factor: i32,
}
impl FenceInner {
const FAILURE_WINDOW: TimeDelta = TimeDelta::minutes(3);
const FAILURE_THRESHOLD: usize = 4;
const BLOCK_DURATION_MIN_BASE: TimeDelta = TimeDelta::minutes(2);
const BLOCK_DURATION_MAX_BASE: TimeDelta = TimeDelta::minutes(5);
const BLOCK_DURATION_MAX_FACTOR: i32 = 3;
pub fn new() -> Self {
Self {
failures: VecDeque::new(),
block_until: None,
block_factor: 0,
}
}
pub fn is_blocked(&mut self) -> bool {
if let Some(until) = self.block_until {
if until > Utc::now() {
return true;
} else {
self.block_until = None;
}
}
false
}
pub fn record_ok(&mut self) {
self.prune(Utc::now());
}
pub fn record_fail(&mut self) {
self.prune(Utc::now());
self.failures.push_back(Utc::now());
if self.failures.len() >= Self::FAILURE_THRESHOLD {
self.trigger_block();
}
}
/// Blocks further requests for a random duration between the min and max base durations, scaled by a factor
/// of how many blocks have been triggered in this session.
///
/// As such, for the first block, the duration will be between 2 and 5 minutes.
/// - For the second block, between 4 and 10 minutes.
/// - For the third block and any further blocks, between 6 and 15 minutes.
fn trigger_block(&mut self) {
self.block_factor =
i32::min(self.block_factor + 1, Self::BLOCK_DURATION_MAX_FACTOR);
let min = Self::BLOCK_DURATION_MIN_BASE
.checked_mul(self.block_factor)
.unwrap_or(Self::BLOCK_DURATION_MIN_BASE);
let max = Self::BLOCK_DURATION_MAX_BASE
.checked_mul(self.block_factor)
.unwrap_or(Self::BLOCK_DURATION_MAX_BASE);
let delta_seconds = (max - min).as_seconds_f64()
* rand::thread_rng().gen_range(0.0..=1.0);
let duration =
min + TimeDelta::milliseconds((delta_seconds * 1000.0) as i64);
self.block_until = Some(Utc::now() + duration);
}
/// Removes all failure points older than the failure window
fn prune(&mut self, now: DateTime<Utc>) {
let cutoff = now - Self::FAILURE_WINDOW;
while let Some(&front) = self.failures.front() {
if front < cutoff {
self.failures.pop_front();
} else {
break;
}
}
}
}
static GLOBAL_FETCH_FENCE: LazyLock<FetchFence> =
LazyLock::new(|| FetchFence {
inner: Mutex::new(FenceInner::new()),
});
pub static REQWEST_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
let mut headers = reqwest::header::HeaderMap::new();
let header =
reqwest::header::HeaderValue::from_str(LAUNCHER_USER_AGENT).unwrap();
reqwest::header::HeaderValue::from_str(&crate::launcher_user_agent())
.unwrap();
headers.insert(reqwest::header::USER_AGENT, header);
reqwest::Client::builder()
.tcp_keepalive(Some(time::Duration::from_secs(10)))
@@ -30,7 +143,8 @@ pub static REQWEST_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
.build()
.expect("Reqwest Client Building Failed")
});
const FETCH_ATTEMPTS: usize = 3;
const FETCH_ATTEMPTS: usize = 2;
#[tracing::instrument(skip(semaphore))]
pub async fn fetch(
@@ -78,12 +192,13 @@ pub async fn fetch_advanced(
) -> crate::Result<Bytes> {
let _permit = semaphore.0.acquire().await?;
let is_api_url = url.starts_with(env!("MODRINTH_API_URL"))
|| url.starts_with(env!("MODRINTH_API_URL_V3"));
let creds = if header
.as_ref()
.is_none_or(|x| &*x.0.to_lowercase() != "authorization")
&& (url.starts_with("https://cdn.modrinth.com")
|| url.starts_with(env!("MODRINTH_API_URL"))
|| url.starts_with(env!("MODRINTH_API_URL_V3")))
&& (url.starts_with("https://cdn.modrinth.com") || is_api_url)
{
crate::state::ModrinthCredentials::get_active(exec).await?
} else {
@@ -91,6 +206,10 @@ pub async fn fetch_advanced(
};
for attempt in 1..=(FETCH_ATTEMPTS + 1) {
if is_api_url && GLOBAL_FETCH_FENCE.is_blocked() {
return Err(ErrorKind::ApiIsDownError.into());
}
let mut req = REQWEST_CLIENT.request(method.clone(), url);
if let Some(body) = json_body.clone() {
@@ -108,10 +227,16 @@ pub async fn fetch_advanced(
let result = req.send().await;
match result {
Ok(resp) => {
if resp.status().is_server_error() && attempt <= FETCH_ATTEMPTS
{
continue;
if resp.status().is_server_error() {
if is_api_url {
GLOBAL_FETCH_FENCE.record_fail();
}
if attempt <= FETCH_ATTEMPTS {
continue;
}
}
if resp.status().is_client_error()
|| resp.status().is_server_error()
{
@@ -166,6 +291,11 @@ pub async fn fetch_advanced(
}
tracing::trace!("Done downloading URL {url}");
if is_api_url {
GLOBAL_FETCH_FENCE.record_ok();
}
return Ok(bytes);
} else if attempt <= FETCH_ATTEMPTS {
continue;
@@ -325,3 +455,124 @@ pub async fn sha1_async(bytes: Bytes) -> crate::Result<String> {
Ok(hash)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeDelta, Utc};
#[test]
fn test_fence_block_after_4_fails() {
// Update tests if the FenceInner constants change
let mut fence = FenceInner::new();
fence.record_fail();
assert!(!fence.is_blocked());
fence.record_fail();
assert!(!fence.is_blocked());
fence.record_fail();
assert!(!fence.is_blocked());
fence.record_fail();
assert!(fence.is_blocked());
}
#[test]
fn test_fence_block_after_4_fails_with_oks() {
// Update tests if the FenceInner constants change
let mut fence = FenceInner::new();
fence.record_fail();
assert!(!fence.is_blocked());
fence.record_fail();
assert!(!fence.is_blocked());
fence.record_ok();
assert!(!fence.is_blocked());
fence.record_fail();
assert!(!fence.is_blocked());
fence.record_fail();
assert!(fence.is_blocked());
}
#[test]
fn test_fence_not_blocked_after_fails_expire() {
// Update tests if the FenceInner constants change
let mut fence = FenceInner::new();
fence.record_fail();
assert!(!fence.is_blocked());
fence.record_fail();
assert!(!fence.is_blocked());
fence.prune(Utc::now() + TimeDelta::seconds(60 * 3 + 55)); // Should prune all failures
fence.record_fail();
assert!(!fence.is_blocked());
fence.record_fail();
assert!(!fence.is_blocked());
fence.record_fail();
assert!(!fence.is_blocked());
fence.record_fail();
assert!(fence.is_blocked());
}
#[test]
fn test_fence_trigger_block_windows() {
// brute force flukes
for i in 0..128 {
let mut fence = FenceInner::new();
fence.trigger_block();
assert!(fence.is_blocked(), "Should be blocked (attempt {i})");
let block_until = fence.block_until.unwrap();
assert!(
block_until > Utc::now() + TimeDelta::seconds(60 + 55),
"Should be more than 2 minutes (with some leeway) (attempt {i})"
); // more than 2 minutes (with some leeway)
assert!(
block_until < Utc::now() + TimeDelta::seconds(60 * 5),
"Should be less than 5 minutes (attempt {i})"
); // less than 5 minutes
fence.block_until = None;
fence.trigger_block();
let block_until = fence.block_until.unwrap();
assert!(
block_until > Utc::now() + TimeDelta::seconds(60 * 3 + 55),
"Should be more than 4 minutes (with some leeway) (attempt {i})"
); // more than 4 minutes (with some leeway)
assert!(
block_until < Utc::now() + TimeDelta::seconds(60 * 10),
"Should be less than 10 minutes (attempt {i})"
); // less than 10 minutes
fence.block_until = None;
fence.trigger_block();
let block_until = fence.block_until.unwrap();
assert!(
block_until > Utc::now() + TimeDelta::seconds(60 * 5 + 55),
"Should be more than 6 minutes (with some leeway) (attempt {i})"
); // more than 6 minutes (with some leeway)
assert!(
block_until < Utc::now() + TimeDelta::seconds(60 * 15),
"Should be less than 15 minutes (attempt {i})"
); // less than 15 minutes
}
}
}

View File

@@ -0,0 +1,19 @@
# Copying
The source code of Modrinth's UI library is licensed under the GNU General Public License, Version 3 only, which is provided in the file [LICENSE](./LICENSE). However, some files listed below are licensed under a different license.
## Modrinth logo
The use of Modrinth branding elements, including but not limited to the wrench-in-labyrinth logo, the landing image, and any variations thereof, is strictly prohibited without explicit written permission from Rinth, Inc. This includes trademarks, logos, or other branding elements.
> All rights reserved. © 2020-2025 Rinth, Inc.
This includes, but may not be limited to, the following files:
- branding/\*
## External logos
The following files are owned by their respective copyright holders and must be used within each of their Brand Guidelines:
- external/\*

View File

@@ -0,0 +1,213 @@
import fs from 'node:fs'
import path from 'node:path'
import readline from 'node:readline'
const packageRoot = path.resolve(__dirname, '..')
const iconsDir = path.join(packageRoot, 'icons')
const lucideIconsDir = path.join(packageRoot, 'node_modules/lucide-static/icons')
function listAvailableIcons(): string[] {
if (!fs.existsSync(lucideIconsDir)) {
return []
}
return fs
.readdirSync(lucideIconsDir)
.filter((file) => file.endsWith('.svg'))
.map((file) => path.basename(file, '.svg'))
.sort()
}
function paginateList(allIcons: string[], pageSize = 20): void {
let page = 0
let search = ''
let filteredIcons = allIcons
const getFilteredIcons = (): string[] => {
if (!search) return allIcons
return allIcons.filter((icon) => icon.includes(search))
}
const renderPage = (): void => {
console.clear()
filteredIcons = getFilteredIcons()
const totalPages = Math.max(1, Math.ceil(filteredIcons.length / pageSize))
if (page >= totalPages) page = Math.max(0, totalPages - 1)
const start = page * pageSize
const end = Math.min(start + pageSize, filteredIcons.length)
const pageIcons = filteredIcons.slice(start, end)
console.log(`\x1b[1mAvailable Lucide Icons\x1b[0m`)
console.log(`\x1b[2mSearch: \x1b[0m${search || '\x1b[2m(type to search)\x1b[0m'}\n`)
if (pageIcons.length === 0) {
console.log(` \x1b[2mNo icons found matching "${search}"\x1b[0m`)
} else {
pageIcons.forEach((icon) => {
if (search) {
const highlighted = icon.replace(search, `\x1b[33m${search}\x1b[0m`)
console.log(` ${highlighted}`)
} else {
console.log(` ${icon}`)
}
})
}
console.log(
`\n\x1b[2m${filteredIcons.length}/${allIcons.length} icons | Page ${page + 1}/${totalPages} | ← → navigate | :q quit\x1b[0m`,
)
}
renderPage()
readline.emitKeypressEvents(process.stdin)
if (process.stdin.isTTY) {
process.stdin.setRawMode(true)
}
process.stdin.on('keypress', (str, key) => {
if (key.ctrl && key.name === 'c') {
console.clear()
process.exit(0)
}
// :q to quit
if (search === ':' && key.name === 'q') {
console.clear()
process.exit(0)
}
// Navigation
if (key.name === 'right') {
const totalPages = Math.max(1, Math.ceil(filteredIcons.length / pageSize))
if (page < totalPages - 1) {
page++
renderPage()
}
return
}
if (key.name === 'left') {
if (page > 0) {
page--
renderPage()
}
return
}
// Backspace
if (key.name === 'backspace') {
search = search.slice(0, -1)
page = 0
renderPage()
return
}
// Escape to clear search
if (key.name === 'escape') {
search = ''
page = 0
renderPage()
return
}
// Type to search
if (str && str.length === 1 && !key.ctrl && !key.meta) {
search += str
page = 0
renderPage()
}
})
}
function addIcon(iconId: string, overwrite: boolean): boolean {
const sourcePath = path.join(lucideIconsDir, `${iconId}.svg`)
const targetPath = path.join(iconsDir, `${iconId}.svg`)
if (!fs.existsSync(sourcePath)) {
console.error(`❌ Icon "${iconId}" not found in lucide-static`)
console.error(` Run with --list to see available icons`)
return false
}
if (fs.existsSync(targetPath) && !overwrite) {
console.log(`⏭️ Skipping "${iconId}" (already exists, use --overwrite to replace)`)
return false
}
fs.copyFileSync(sourcePath, targetPath)
console.log(`✅ Added "${iconId}"`)
return true
}
function main(): void {
const args = process.argv.slice(2)
if (args.includes('--help') || args.includes('-h')) {
console.log(`
Usage: pnpm icons:add [options] <icon_id> [icon_id...]
Options:
--list, -l Browse all available Lucide icons (interactive)
--overwrite, -o Overwrite existing icons
--help, -h Show this help message
Examples:
pnpm icons:add heart star settings-2
pnpm icons:add --overwrite heart
pnpm icons:add --list # Interactive browser
pnpm icons:add --list | grep arrow # Pipe to grep
Interactive controls:
Type Search icons
← → Navigate pages
Escape Clear search
:q Quit
`)
process.exit(0)
}
if (args.includes('--list') || args.includes('-l')) {
const icons = listAvailableIcons()
if (icons.length === 0) {
console.error('❌ lucide-static not installed. Run pnpm install first.')
process.exit(1)
}
if (process.stdout.isTTY) {
paginateList(icons)
} else {
// Non-interactive mode (piped output)
icons.forEach((icon) => console.log(icon))
process.exit(0)
}
return
}
const overwrite = args.includes('--overwrite') || args.includes('-o')
const iconIds = args.filter((arg) => !arg.startsWith('-'))
if (iconIds.length === 0) {
console.error('Usage: pnpm icons:add <icon_id> [icon_id...]')
console.error('Example: pnpm icons:add heart star settings-2')
console.error('Run with --help for more options')
process.exit(1)
}
if (!fs.existsSync(lucideIconsDir)) {
console.error('❌ lucide-static not installed. Run pnpm install first.')
process.exit(1)
}
let added = 0
for (const iconId of iconIds) {
if (addIcon(iconId, overwrite)) {
added++
}
}
if (added > 0) {
console.log(`\n📦 Added ${added} icon(s). Run 'pnpm prepr:frontend:lib' to update exports.`)
}
}
main()

View File

@@ -1,3 +1,4 @@
import { compareImportSources } from '@modrinth/tooling-config/script-utils/import-sort'
import fs from 'fs'
import path from 'path'
@@ -22,15 +23,10 @@ function generateIconExports(): { imports: string; exports: string } {
throw new Error(`Icons directory not found: ${iconsDir}`)
}
const files = fs
.readdirSync(iconsDir)
.filter((file) => file.endsWith('.svg'))
.sort()
const files = fs.readdirSync(iconsDir).filter((file) => file.endsWith('.svg'))
let imports = ''
let exports = ''
files.forEach((file) => {
// Build icon data with import paths
const icons = files.map((file) => {
const baseName = path.basename(file, '.svg')
let pascalName = toPascalCase(baseName)
@@ -42,9 +38,21 @@ function generateIconExports(): { imports: string; exports: string } {
pascalName += 'Icon'
}
const privateName = `_${pascalName}`
return {
importPath: `./icons/${file}?component`,
pascalName,
privateName: `_${pascalName}`,
}
})
imports += `import ${privateName} from './icons/${file}?component'\n`
// Sort by import path using simple-import-sort's algorithm
icons.sort((a, b) => compareImportSources(a.importPath, b.importPath))
let imports = ''
let exports = ''
icons.forEach(({ importPath, pascalName, privateName }) => {
imports += `import ${privateName} from '${importPath}'\n`
exports += `export const ${pascalName} = ${privateName}\n`
})

7
packages/assets/external/flathub.svg vendored Normal file
View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0.04 -1.13 62.99 60.24">
<g fill="currentColor" stroke-width="1.123" transform="matrix(1.56993 0 0 1.56993 -247.445 -135.95)">
<circle cx="166.69" cy="95.647" r="9.048"/>
<rect width="15.875" height="15.875" x="158.75" y="108.33" rx="4.233" ry="4.233"/>
<path d="m195.534 93.49-1.622-.938-9.339-5.391a2.572 2.572 0 0 0-3.857 2.227v12.657a2.572 2.572 0 0 0 3.858 2.227l10.96-6.328a2.572 2.572 0 0 0 0-4.455zM194.99 116.31c0 .88-.708 1.587-1.587 1.587h-12.7c-.88 0-1.588-.708-1.588-1.587 0-.88.708-1.588 1.587-1.588h12.7c.88 0 1.588.709 1.588 1.588zm-7.938-7.938c.88 0 1.588.709 1.588 1.588v12.7c0 .88-.708 1.587-1.587 1.587-.88 0-1.588-.708-1.588-1.587v-12.7c0-.88.708-1.587 1.587-1.587z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 759 B

View File

@@ -8,6 +8,7 @@ import _ArrowBigRightDashIcon from './icons/arrow-big-right-dash.svg?component'
import _ArrowBigUpDashIcon from './icons/arrow-big-up-dash.svg?component'
import _ArrowDownIcon from './icons/arrow-down.svg?component'
import _ArrowDownLeftIcon from './icons/arrow-down-left.svg?component'
import _ArrowLeftIcon from './icons/arrow-left.svg?component'
import _ArrowLeftRightIcon from './icons/arrow-left-right.svg?component'
import _ArrowUpIcon from './icons/arrow-up.svg?component'
import _ArrowUpRightIcon from './icons/arrow-up-right.svg?component'
@@ -17,6 +18,7 @@ import _BadgeDollarSignIcon from './icons/badge-dollar-sign.svg?component'
import _BanIcon from './icons/ban.svg?component'
import _BellIcon from './icons/bell.svg?component'
import _BellRingIcon from './icons/bell-ring.svg?component'
import _BlendIcon from './icons/blend.svg?component'
import _BlocksIcon from './icons/blocks.svg?component'
import _BoldIcon from './icons/bold.svg?component'
import _BookIcon from './icons/book.svg?component'
@@ -26,6 +28,7 @@ import _BookmarkIcon from './icons/bookmark.svg?component'
import _BotIcon from './icons/bot.svg?component'
import _BoxIcon from './icons/box.svg?component'
import _BoxImportIcon from './icons/box-import.svg?component'
import _BoxesIcon from './icons/boxes.svg?component'
import _BracesIcon from './icons/braces.svg?component'
import _BrushCleaningIcon from './icons/brush-cleaning.svg?component'
import _BugIcon from './icons/bug.svg?component'
@@ -39,6 +42,7 @@ import _CheckCircleIcon from './icons/check-circle.svg?component'
import _ChevronDownIcon from './icons/chevron-down.svg?component'
import _ChevronLeftIcon from './icons/chevron-left.svg?component'
import _ChevronRightIcon from './icons/chevron-right.svg?component'
import _ChevronUpIcon from './icons/chevron-up.svg?component'
import _CircleUserIcon from './icons/circle-user.svg?component'
import _ClearIcon from './icons/clear.svg?component'
import _ClientIcon from './icons/client.svg?component'
@@ -61,6 +65,7 @@ import _CubeIcon from './icons/cube.svg?component'
import _CurrencyIcon from './icons/currency.svg?component'
import _DashboardIcon from './icons/dashboard.svg?component'
import _DatabaseIcon from './icons/database.svg?component'
import _DatabaseBackupIcon from './icons/database-backup.svg?component'
import _DownloadIcon from './icons/download.svg?component'
import _DropdownIcon from './icons/dropdown.svg?component'
import _EditIcon from './icons/edit.svg?component'
@@ -73,11 +78,13 @@ import _FileIcon from './icons/file.svg?component'
import _FileArchiveIcon from './icons/file-archive.svg?component'
import _FileCodeIcon from './icons/file-code.svg?component'
import _FileImageIcon from './icons/file-image.svg?component'
import _FilePlusIcon from './icons/file-plus.svg?component'
import _FileTextIcon from './icons/file-text.svg?component'
import _FilterIcon from './icons/filter.svg?component'
import _FilterXIcon from './icons/filter-x.svg?component'
import _FolderIcon from './icons/folder.svg?component'
import _FolderArchiveIcon from './icons/folder-archive.svg?component'
import _FolderCogIcon from './icons/folder-cog.svg?component'
import _FolderOpenIcon from './icons/folder-open.svg?component'
import _FolderSearchIcon from './icons/folder-search.svg?component'
import _FolderUpIcon from './icons/folder-up.svg?component'
@@ -112,6 +119,7 @@ import _KeyIcon from './icons/key.svg?component'
import _KeyboardIcon from './icons/keyboard.svg?component'
import _LandmarkIcon from './icons/landmark.svg?component'
import _LanguagesIcon from './icons/languages.svg?component'
import _LayoutTemplateIcon from './icons/layout-template.svg?component'
import _LeftArrowIcon from './icons/left-arrow.svg?component'
import _LibraryIcon from './icons/library.svg?component'
import _LightBulbIcon from './icons/light-bulb.svg?component'
@@ -149,6 +157,7 @@ import _PackageIcon from './icons/package.svg?component'
import _PackageClosedIcon from './icons/package-closed.svg?component'
import _PackageOpenIcon from './icons/package-open.svg?component'
import _PaintbrushIcon from './icons/paintbrush.svg?component'
import _PaletteIcon from './icons/palette.svg?component'
import _PickaxeIcon from './icons/pickaxe.svg?component'
import _PlayIcon from './icons/play.svg?component'
import _PlugIcon from './icons/plug.svg?component'
@@ -232,26 +241,29 @@ export const AlignLeftIcon = _AlignLeftIcon
export const ArchiveIcon = _ArchiveIcon
export const ArrowBigRightDashIcon = _ArrowBigRightDashIcon
export const ArrowBigUpDashIcon = _ArrowBigUpDashIcon
export const ArrowDownLeftIcon = _ArrowDownLeftIcon
export const ArrowDownIcon = _ArrowDownIcon
export const ArrowDownLeftIcon = _ArrowDownLeftIcon
export const ArrowLeftIcon = _ArrowLeftIcon
export const ArrowLeftRightIcon = _ArrowLeftRightIcon
export const ArrowUpRightIcon = _ArrowUpRightIcon
export const ArrowUpIcon = _ArrowUpIcon
export const ArrowUpRightIcon = _ArrowUpRightIcon
export const AsteriskIcon = _AsteriskIcon
export const BadgeCheckIcon = _BadgeCheckIcon
export const BadgeDollarSignIcon = _BadgeDollarSignIcon
export const BanIcon = _BanIcon
export const BellRingIcon = _BellRingIcon
export const BellIcon = _BellIcon
export const BellRingIcon = _BellRingIcon
export const BlendIcon = _BlendIcon
export const BlocksIcon = _BlocksIcon
export const BoldIcon = _BoldIcon
export const BookIcon = _BookIcon
export const BookOpenIcon = _BookOpenIcon
export const BookTextIcon = _BookTextIcon
export const BookIcon = _BookIcon
export const BookmarkIcon = _BookmarkIcon
export const BotIcon = _BotIcon
export const BoxImportIcon = _BoxImportIcon
export const BoxIcon = _BoxIcon
export const BoxImportIcon = _BoxImportIcon
export const BoxesIcon = _BoxesIcon
export const BracesIcon = _BracesIcon
export const BrushCleaningIcon = _BrushCleaningIcon
export const BugIcon = _BugIcon
@@ -259,12 +271,13 @@ export const CalendarIcon = _CalendarIcon
export const CardIcon = _CardIcon
export const ChangeSkinIcon = _ChangeSkinIcon
export const ChartIcon = _ChartIcon
export const CheckIcon = _CheckIcon
export const CheckCheckIcon = _CheckCheckIcon
export const CheckCircleIcon = _CheckCircleIcon
export const CheckIcon = _CheckIcon
export const ChevronDownIcon = _ChevronDownIcon
export const ChevronLeftIcon = _ChevronLeftIcon
export const ChevronRightIcon = _ChevronRightIcon
export const ChevronUpIcon = _ChevronUpIcon
export const CircleUserIcon = _CircleUserIcon
export const ClearIcon = _ClearIcon
export const ClientIcon = _ClientIcon
@@ -287,25 +300,28 @@ export const CubeIcon = _CubeIcon
export const CurrencyIcon = _CurrencyIcon
export const DashboardIcon = _DashboardIcon
export const DatabaseIcon = _DatabaseIcon
export const DatabaseBackupIcon = _DatabaseBackupIcon
export const DownloadIcon = _DownloadIcon
export const DropdownIcon = _DropdownIcon
export const EditIcon = _EditIcon
export const EllipsisVerticalIcon = _EllipsisVerticalIcon
export const ExpandIcon = _ExpandIcon
export const ExternalIcon = _ExternalIcon
export const EyeOffIcon = _EyeOffIcon
export const EyeIcon = _EyeIcon
export const EyeOffIcon = _EyeOffIcon
export const FileIcon = _FileIcon
export const FileArchiveIcon = _FileArchiveIcon
export const FileCodeIcon = _FileCodeIcon
export const FileImageIcon = _FileImageIcon
export const FilePlusIcon = _FilePlusIcon
export const FileTextIcon = _FileTextIcon
export const FileIcon = _FileIcon
export const FilterXIcon = _FilterXIcon
export const FilterIcon = _FilterIcon
export const FilterXIcon = _FilterXIcon
export const FolderIcon = _FolderIcon
export const FolderArchiveIcon = _FolderArchiveIcon
export const FolderCogIcon = _FolderCogIcon
export const FolderOpenIcon = _FolderOpenIcon
export const FolderSearchIcon = _FolderSearchIcon
export const FolderIcon = _FolderIcon
export const FolderUpIcon = _FolderUpIcon
export const GameIcon = _GameIcon
export const GapIcon = _GapIcon
@@ -323,9 +339,9 @@ export const HashIcon = _HashIcon
export const Heading1Icon = _Heading1Icon
export const Heading2Icon = _Heading2Icon
export const Heading3Icon = _Heading3Icon
export const HeartIcon = _HeartIcon
export const HeartHandshakeIcon = _HeartHandshakeIcon
export const HeartMinusIcon = _HeartMinusIcon
export const HeartIcon = _HeartIcon
export const HistoryIcon = _HistoryIcon
export const HomeIcon = _HomeIcon
export const ImageIcon = _ImageIcon
@@ -338,19 +354,20 @@ export const KeyIcon = _KeyIcon
export const KeyboardIcon = _KeyboardIcon
export const LandmarkIcon = _LandmarkIcon
export const LanguagesIcon = _LanguagesIcon
export const LayoutTemplateIcon = _LayoutTemplateIcon
export const LeftArrowIcon = _LeftArrowIcon
export const LibraryIcon = _LibraryIcon
export const LightBulbIcon = _LightBulbIcon
export const LinkIcon = _LinkIcon
export const ListIcon = _ListIcon
export const ListBulletedIcon = _ListBulletedIcon
export const ListEndIcon = _ListEndIcon
export const ListFilterIcon = _ListFilterIcon
export const ListOrderedIcon = _ListOrderedIcon
export const ListIcon = _ListIcon
export const LoaderCircleIcon = _LoaderCircleIcon
export const LoaderIcon = _LoaderIcon
export const LockOpenIcon = _LockOpenIcon
export const LoaderCircleIcon = _LoaderCircleIcon
export const LockIcon = _LockIcon
export const LockOpenIcon = _LockOpenIcon
export const LogInIcon = _LogInIcon
export const LogOutIcon = _LogOutIcon
export const MailIcon = _MailIcon
@@ -361,8 +378,8 @@ export const MessageIcon = _MessageIcon
export const MicrophoneIcon = _MicrophoneIcon
export const MinimizeIcon = _MinimizeIcon
export const MinusIcon = _MinusIcon
export const MonitorSmartphoneIcon = _MonitorSmartphoneIcon
export const MonitorIcon = _MonitorIcon
export const MonitorSmartphoneIcon = _MonitorSmartphoneIcon
export const MoonIcon = _MoonIcon
export const MoreHorizontalIcon = _MoreHorizontalIcon
export const MoreVerticalIcon = _MoreVerticalIcon
@@ -371,16 +388,17 @@ export const NoSignalIcon = _NoSignalIcon
export const NotepadTextIcon = _NotepadTextIcon
export const OmorphiaIcon = _OmorphiaIcon
export const OrganizationIcon = _OrganizationIcon
export const PackageIcon = _PackageIcon
export const PackageClosedIcon = _PackageClosedIcon
export const PackageOpenIcon = _PackageOpenIcon
export const PackageIcon = _PackageIcon
export const PaintbrushIcon = _PaintbrushIcon
export const PaletteIcon = _PaletteIcon
export const PickaxeIcon = _PickaxeIcon
export const PlayIcon = _PlayIcon
export const PlugIcon = _PlugIcon
export const PlusIcon = _PlusIcon
export const RadioButtonCheckedIcon = _RadioButtonCheckedIcon
export const RadioButtonIcon = _RadioButtonIcon
export const RadioButtonCheckedIcon = _RadioButtonCheckedIcon
export const ReceiptTextIcon = _ReceiptTextIcon
export const RedoIcon = _RedoIcon
export const RefreshCwIcon = _RefreshCwIcon
@@ -397,13 +415,13 @@ export const ScaleIcon = _ScaleIcon
export const ScanEyeIcon = _ScanEyeIcon
export const SearchIcon = _SearchIcon
export const SendIcon = _SendIcon
export const ServerPlusIcon = _ServerPlusIcon
export const ServerIcon = _ServerIcon
export const ServerPlusIcon = _ServerPlusIcon
export const SettingsIcon = _SettingsIcon
export const ShareIcon = _ShareIcon
export const ShieldIcon = _ShieldIcon
export const ShieldAlertIcon = _ShieldAlertIcon
export const ShieldCheckIcon = _ShieldCheckIcon
export const ShieldIcon = _ShieldIcon
export const SignalIcon = _SignalIcon
export const SkullIcon = _SkullIcon
export const SlashIcon = _SlashIcon
@@ -430,25 +448,25 @@ export const TrashIcon = _TrashIcon
export const TriangleAlertIcon = _TriangleAlertIcon
export const UnderlineIcon = _UnderlineIcon
export const UndoIcon = _UndoIcon
export const UnknownDonationIcon = _UnknownDonationIcon
export const UnknownIcon = _UnknownIcon
export const UnknownDonationIcon = _UnknownDonationIcon
export const UnlinkIcon = _UnlinkIcon
export const UnplugIcon = _UnplugIcon
export const UpdatedIcon = _UpdatedIcon
export const UploadIcon = _UploadIcon
export const UserIcon = _UserIcon
export const UserCogIcon = _UserCogIcon
export const UserPlusIcon = _UserPlusIcon
export const UserRoundIcon = _UserRoundIcon
export const UserSearchIcon = _UserSearchIcon
export const UserXIcon = _UserXIcon
export const UserIcon = _UserIcon
export const UsersIcon = _UsersIcon
export const VersionIcon = _VersionIcon
export const WikiIcon = _WikiIcon
export const WindowIcon = _WindowIcon
export const WorldIcon = _WorldIcon
export const WrenchIcon = _WrenchIcon
export const XCircleIcon = _XCircleIcon
export const XIcon = _XIcon
export const XCircleIcon = _XCircleIcon
export const ZoomInIcon = _ZoomInIcon
export const ZoomOutIcon = _ZoomOutIcon

View File

@@ -0,0 +1,16 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-arrow-left"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m12 19-7-7 7-7" />
<path d="M19 12H5" />
</svg>

After

Width:  |  Height:  |  Size: 344 B

View File

@@ -0,0 +1,16 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-blend"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="9" cy="9" r="7" />
<circle cx="15" cy="15" r="7" />
</svg>

After

Width:  |  Height:  |  Size: 353 B

View File

@@ -0,0 +1,26 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-boxes"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z" />
<path d="m7 16.5-4.74-2.85" />
<path d="m7 16.5 5-3" />
<path d="M7 16.5v5.17" />
<path d="M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z" />
<path d="m17 16.5-5-3" />
<path d="m17 16.5 4.74-2.85" />
<path d="M17 16.5v5.17" />
<path d="M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0l-3 1.8Z" />
<path d="M12 8 7.26 5.15" />
<path d="m12 8 4.74-2.85" />
<path d="M12 13.5V8" />
</svg>

After

Width:  |  Height:  |  Size: 901 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="lucide lucide-chevron-up-icon lucide-chevron-up">
<path d="m18 15-6-6-6 6" />
</svg>

After

Width:  |  Height:  |  Size: 276 B

View File

@@ -0,0 +1,20 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-database-backup"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M3 12a9 3 0 0 0 5 2.69" />
<path d="M21 9.3V5" />
<path d="M3 5v14a9 3 0 0 0 6.47 2.88" />
<path d="M12 12v4h4" />
<path d="M13 20a5 5 0 0 0 9-3 4.5 4.5 0 0 0-4.5-4.5c-1.33 0-2.54.54-3.41 1.41L12 16" />
</svg>

After

Width:  |  Height:  |  Size: 560 B

View File

@@ -0,0 +1,18 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-file-plus"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" />
<path d="M14 2v5a1 1 0 0 0 1 1h5" />
<path d="M9 15h6" />
<path d="M12 18v-6" />
</svg>

After

Width:  |  Height:  |  Size: 502 B

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="lucide lucide-folder-cog-icon lucide-folder-cog">
<path d="M10.3 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.98a2 2 0 0 1 1.69.9l.66 1.2A2 2 0 0 0 12 6h8a2 2 0 0 1 2 2v3.3" />
<path d="m14.305 19.53.923-.382" />
<path d="m15.228 16.852-.923-.383" />
<path d="m16.852 15.228-.383-.923" />
<path d="m16.852 20.772-.383.924" />
<path d="m19.148 15.228.383-.923" />
<path d="m19.53 21.696-.382-.924" />
<path d="m20.772 16.852.924-.383" />
<path d="m20.772 19.148.924.383" />
<circle cx="18" cy="18" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 705 B

View File

@@ -0,0 +1,17 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-layout-template"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="7" x="3" y="3" rx="1" />
<rect width="9" height="7" x="3" y="14" rx="1" />
<rect width="5" height="7" x="16" y="14" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 452 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-palette" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 21a9 9 0 0 1 0 -18c4.97 0 9 3.582 9 8c0 1.06 -.474 2.078 -1.318 2.828c-.844 .75 -1.989 1.172 -3.182 1.172h-2.5a2 2 0 0 0 -1 3.75a1.3 1.3 0 0 1 -1 2.25" /><path d="M8.5 10.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M12.5 7.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M16.5 10.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /></svg>

After

Width:  |  Height:  |  Size: 619 B

View File

@@ -39,6 +39,7 @@ import _VenmoColorIcon from './external/color/venmo.svg?component'
import _CurseForgeIcon from './external/curseforge.svg?component'
import _DiscordIcon from './external/discord.svg?component'
import _FacebookIcon from './external/facebook.svg?component'
import _FlathubIcon from './external/flathub.svg?component'
import _GithubIcon from './external/github.svg?component'
import _MinecraftServerIcon from './external/illustrations/minecraft_server_icon.png?url'
import _InstagramIcon from './external/instagram.svg?component'
@@ -93,6 +94,7 @@ export const GithubIcon = _GithubIcon
export const CurseForgeIcon = _CurseForgeIcon
export const DiscordIcon = _DiscordIcon
export const FacebookIcon = _FacebookIcon
export const FlathubIcon = _FlathubIcon
export const InstagramIcon = _InstagramIcon
export const SnapchatIcon = _SnapchatIcon
export const ReelsIcon = _ReelsIcon

View File

@@ -7,13 +7,16 @@
"scripts": {
"lint": "pnpm run icons:validate && eslint . && prettier --check .",
"fix": "pnpm run icons:generate && eslint . --fix && prettier --write .",
"icons:add": "jiti build/add-icons.ts",
"icons:test": "jiti build/generate-exports.ts --test",
"icons:validate": "jiti build/generate-exports.ts --validate",
"icons:generate": "jiti build/generate-exports.ts"
},
"devDependencies": {
"@modrinth/tooling-config": "workspace:*",
"@types/node": "^20.1.0",
"jiti": "^2.4.2",
"lucide-static": "^0.562.0",
"vue": "^3.5.13"
}
}

View File

@@ -0,0 +1,211 @@
.ace-modrinth .ace_gutter {
background: var(--surface-2);
color: var(--color-text-tertiary);
}
.ace-modrinth .ace_print-margin {
width: 1px;
background: var(--surface-5);
}
.ace-modrinth {
background-color: var(--surface-3);
color: var(--color-text-default);
}
.ace-modrinth .ace_cursor {
color: var(--color-brand);
}
.ace-modrinth .ace_marker-layer .ace_selection {
background: var(--color-brand-highlight);
}
.ace-modrinth.ace_multiselect .ace_selection.ace_start {
box-shadow: 0 0 3px 0 var(--surface-3);
}
.ace-modrinth .ace_marker-layer .ace_step {
background: var(--color-orange-highlight);
}
.ace-modrinth .ace_marker-layer .ace_bracket {
margin: -1px 0 0 -1px;
border: 1px solid var(--color-brand);
}
.ace-modrinth .ace_marker-layer .ace_active-line {
background: var(--surface-4);
}
.ace-modrinth .ace_gutter-active-line {
background-color: var(--surface-1);
}
.ace-modrinth .ace_marker-layer .ace_selected-word {
border: 1px solid var(--color-brand-highlight);
}
.ace-modrinth .ace_invisible {
color: var(--color-text-tertiary);
opacity: 0.5;
}
.ace-modrinth .ace_fold {
background-color: var(--color-blue);
border-color: var(--color-text-default);
}
/* Comments */
.ace-modrinth .ace_comment {
color: var(--color-gray);
font-style: italic;
}
/* Strings */
.ace-modrinth .ace_string {
color: var(--color-green);
}
.ace-modrinth .ace_string.ace_regexp {
color: var(--color-orange);
}
/* Constants */
.ace-modrinth .ace_constant.ace_numeric {
color: var(--color-purple);
}
.ace-modrinth .ace_constant.ace_language {
color: var(--color-orange);
}
.ace-modrinth .ace_constant.ace_character {
color: var(--color-orange);
}
.ace-modrinth .ace_constant.ace_other {
color: var(--color-purple);
}
/* Keywords */
.ace-modrinth .ace_keyword {
color: var(--color-red);
}
.ace-modrinth .ace_keyword.ace_operator {
color: var(--color-text-default);
}
/* Storage */
.ace-modrinth .ace_storage {
color: var(--color-red);
}
.ace-modrinth .ace_storage.ace_type {
color: var(--color-blue);
}
/* Entity names */
.ace-modrinth .ace_entity.ace_name.ace_function {
color: var(--color-blue);
}
.ace-modrinth .ace_entity.ace_name.ace_class {
color: var(--color-purple);
}
.ace-modrinth .ace_entity.ace_name.ace_tag {
color: var(--color-red);
}
.ace-modrinth .ace_entity.ace_other.ace_attribute-name {
color: var(--color-orange);
}
/* Variables */
.ace-modrinth .ace_variable {
color: var(--color-text-default);
}
.ace-modrinth .ace_variable.ace_parameter {
color: var(--color-text-default);
font-style: italic;
}
.ace-modrinth .ace_variable.ace_language {
color: var(--color-orange);
}
/* Support */
.ace-modrinth .ace_support.ace_function {
color: var(--color-blue);
}
.ace-modrinth .ace_support.ace_class {
color: var(--color-purple);
}
.ace-modrinth .ace_support.ace_type {
color: var(--color-blue);
}
.ace-modrinth .ace_support.ace_constant {
color: var(--color-purple);
}
/* Invalid */
.ace-modrinth .ace_invalid {
color: var(--color-text-primary);
background-color: var(--color-red-bg);
}
.ace-modrinth .ace_invalid.ace_deprecated {
color: var(--color-text-primary);
background-color: var(--color-orange-bg);
}
/* Punctuation */
.ace-modrinth .ace_punctuation {
color: var(--color-text-tertiary);
}
/* Meta */
.ace-modrinth .ace_meta.ace_tag {
color: var(--color-text-default);
}
/* Markup */
.ace-modrinth .ace_markup.ace_heading {
color: var(--color-red);
font-weight: bold;
}
.ace-modrinth .ace_markup.ace_list {
color: var(--color-orange);
}
.ace-modrinth .ace_markup.ace_bold {
font-weight: bold;
}
.ace-modrinth .ace_markup.ace_italic {
font-style: italic;
}
.ace-modrinth .ace_markup.ace_underline {
text-decoration: underline;
}
/* Indent guide */
.ace-modrinth .ace_indent-guide {
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAEklEQVQImWNgYGBgYHB3d/8PAAOIAdULw8qMAAAAAElFTkSuQmCC)
right repeat-y;
opacity: 0.2;
}
.ace-modrinth .ace_indent-guide-active {
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAEklEQVQImWNgYGBgYHB3d/8PAAOIAdULw8qMAAAAAElFTkSuQmCC)
right repeat-y;
opacity: 0.4;
}

View File

@@ -46,13 +46,13 @@
}
> :where(
input + *,
.input-group + *,
.textarea-wrapper + *,
.chips + *,
.resizable-textarea-wrapper + *,
.input-div + *
) {
input + *,
.input-group + *,
.textarea-wrapper + *,
.chips + *,
.resizable-textarea-wrapper + *,
.input-div + *
) {
margin-block-start: var(--gap-md);
}

View File

@@ -7,8 +7,9 @@ body {
// Defaults
background-color: var(--color-bg);
color: var(--color-base);
--font-standard: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto,
Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
--font-standard:
Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell,
Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
--mono-font: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
font-family: var(--font-standard);
font-size: 16px;
@@ -66,15 +67,6 @@ textarea,
border: none;
outline: none;
&:focus,
&:focus-visible {
box-shadow:
inset 0 0 0 transparent,
0 0 0 0.25rem var(--color-brand-shadow);
color: var(--color-contrast);
outline: none;
}
&:disabled,
&[disabled] {
opacity: 0.6;
@@ -94,6 +86,7 @@ textarea,
.cm-content {
white-space: pre-wrap !important;
box-shadow: none;
}
input[type='number'] {

View File

@@ -127,10 +127,12 @@
--shadow-inset-sm: inset 0px -1px 2px hsla(221, 39%, 91%, 0.15);
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
--shadow-raised: 0.3px 0.5px 0.6px hsl(var(--shadow-color) / 0.15),
--shadow-raised:
0.3px 0.5px 0.6px hsl(var(--shadow-color) / 0.15),
1px 2px 2.2px -1.7px hsl(var(--shadow-color) / 0.12),
4.4px 8.8px 9.7px -3.4px hsl(var(--shadow-color) / 0.09);
--shadow-floating: hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
--shadow-floating:
hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, hsla(0, 0%, 0%, 0.1) 0px 2px 4px -1px;
--shadow-card: rgba(50, 50, 100, 0.1) 0px 2px 4px 0px;
@@ -348,7 +350,8 @@ html {
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
--shadow-raised: 0px -2px 4px hsla(221, 39%, 11%, 0.1);
--shadow-floating: hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
--shadow-floating:
hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;
--shadow-card: rgba(0, 0, 0, 0.25) 0px 2px 4px 0px;

15
packages/blog/COPYING.md Normal file
View File

@@ -0,0 +1,15 @@
# Copying
The source code of Modrinth's blog is licensed under the GNU General Public License, Version 3 only, which is provided in the file [LICENSE](./LICENSE). However, some files listed below are licensed under a different license.
## Modrinth logo
The use of Modrinth branding elements, including but not limited to the wrench-in-labyrinth logo, the landing image, and any variations thereof, is strictly prohibited without explicit written permission from Rinth, Inc. This includes trademarks, logos, or other branding elements. The content of the blog articles and images used in them are also subject to the same restrictions.
> All rights reserved. © 2020-2025 Rinth, Inc.
This includes, but may not be limited to, the following files:
- src/articles/\*
- src/compiled/\*
- public/\*

View File

@@ -1,3 +1,4 @@
import { compareImportSources } from '@modrinth/tooling-config/script-utils/import-sort'
import { md } from '@modrinth/utils'
import { promises as fs } from 'fs'
import { glob } from 'glob'
@@ -165,12 +166,20 @@ export const article = {
console.log(`📂 Compiled ${files.length} articles.`)
// Sort imports using simple-import-sort's algorithm to avoid ESLint reformatting
const articleData = articlesArray.map((varName, i) => ({
varName,
importPath: `./${varName}`,
exportLine: articleExports[i],
}))
articleData.sort((a, b) => compareImportSources(a.importPath, b.importPath))
const rootExport = `
// AUTO-GENERATED FILE - DO NOT EDIT
${articleExports.join('\n')}
${articleData.map((a) => a.exportLine).join('\n')}
export const articles = [
${articlesArray.join(',\n ')}
${articleData.map((a) => a.varName).join(',\n ')}
];
`.trimStart()

View File

@@ -35,38 +35,38 @@ import { article as whats_modrinth } from "./whats_modrinth";
import { article as windows_borderless_malware_disclosure } from "./windows_borderless_malware_disclosure";
export const articles = [
windows_borderless_malware_disclosure,
whats_modrinth,
two_years_of_modrinth,
two_years_of_modrinth_history,
streamlined_version_creation,
a_new_chapter_for_modrinth_servers,
accelerating_development,
becoming_sustainable,
capital_return,
carbon_ads,
creator_monetization,
creator_update,
creator_updates_july_2025,
creator_withdrawals_overhaul,
design_refresh,
download_adjustment,
free_server_medal,
knossos_v2_1_0,
licensing_guide,
modpack_changes,
modpacks_alpha,
modrinth_app_beta,
modrinth_beta,
modrinth_servers_asia,
modrinth_servers_beta,
new_environments,
new_site_beta,
plugins_resource_packs,
pride_campaign_2025,
redesign,
russian_censorship,
skins_now_in_modrinth_app,
standing_by_our_values,
standing_by_our_values_russian,
skins_now_in_modrinth_app,
russian_censorship,
redesign,
pride_campaign_2025,
plugins_resource_packs,
new_site_beta,
new_environments,
modrinth_servers_beta,
modrinth_servers_asia,
modrinth_beta,
modrinth_app_beta,
modpacks_alpha,
modpack_changes,
licensing_guide,
knossos_v2_1_0,
free_server_medal,
download_adjustment,
design_refresh,
creator_withdrawals_overhaul,
creator_updates_july_2025,
creator_update,
creator_monetization,
carbon_ads,
capital_return,
becoming_sustainable,
accelerating_development,
a_new_chapter_for_modrinth_servers
streamlined_version_creation,
two_years_of_modrinth,
two_years_of_modrinth_history,
whats_modrinth,
windows_borderless_malware_disclosure
];

View File

@@ -454,6 +454,7 @@ pub enum Argument {
/// An argument which is only applied if certain conditions are met
Ruled {
/// The rules deciding whether the argument(s) is used or not
#[serde(default)]
rules: Vec<Rule>,
/// The container of the argument(s) that should be applied accordingly
value: ArgumentValue,
@@ -461,13 +462,15 @@ pub enum Argument {
}
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Hash, Clone, Copy)]
#[serde(rename_all = "snake_case")]
#[serde(rename_all = "kebab-case")]
/// The type of argument
pub enum ArgumentType {
/// The argument is passed to the game
Game,
/// The argument is passed to the JVM
Jvm,
/// Passed to JVM as well. Includes default arguments to the GC.
DefaultUserJvm,
}
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Hash)]

View File

@@ -16,7 +16,7 @@
},
"devDependencies": {
"@formatjs/cli": "^6.2.12",
"@vintl/vintl": "^4.4.1",
"@modrinth/tooling-config": "workspace:*"
"@modrinth/tooling-config": "workspace:*",
"@modrinth/ui": "workspace:*"
}
}

View File

@@ -2,13 +2,14 @@ import type { Stage } from '../types/stage'
import modpackPermissionsStage from './modpack-permissions-stage'
import categories from './stages/categories'
import description from './stages/description'
import environment from './stages/environment/environment'
import environmentMultiple from './stages/environment/environment-multiple'
import gallery from './stages/gallery'
import license from './stages/license'
import links from './stages/links'
import postApproval from './stages/post-approval'
import reupload from './stages/reupload'
import ruleFollowing from './stages/rule-following'
import sideTypes from './stages/side-types'
import statusAlerts from './stages/status-alerts'
import summary from './stages/summary'
import titleSlug from './stages/title-slug'
@@ -22,7 +23,8 @@ export default [
links,
license,
categories,
sideTypes,
environment,
environmentMultiple,
gallery,
versions,
reupload,

View File

@@ -0,0 +1,2 @@
**Unique environments:** %PROJECT_V3_ENVIRONMENT_COUNT% \
**Environments:** `%PROJECT_V3_ALL_ENVIRONMENTS%`

View File

@@ -1,2 +1,4 @@
**Environment:** `%PROJECT_V3_ENVIRONMENT_0%`
**Client:** `%PROJECT_CLIENT_SIDE%` \
**Server:** `%PROJECT_SERVER_SIDE%`

View File

@@ -0,0 +1,6 @@
## Environment Metadata
Per section 5.1 of %RULES%, it is important that the metadata of your projects is accurate, including Environment Information.
We've recently overhauled how environment metadata works on Modrinth, you can now edit this in your project's [Version Settings](https://modrinth.com/project/%PROJECT_ID%/settings/versions).
Please [read this blogpost](%NEW_ENVIRONMENTS_LINK%) for full details and information on how to ensure your project is labeled correctly.

View File

@@ -1,6 +1,6 @@
## Insufficient Gallery Images
We ask that projects like yours show off their content using images in the Gallery, or optionally in the Description, in order to effectively and clearly inform users of its content per section 2.1 of %RULES%.
We ask that projects like yours show off their content using images in the %PROJECT_GALLERY_FLINK%, or optionally in the Description, in order to effectively and clearly inform users of its content per section 2.1 of %RULES%.
Keep in mind that you should:
- Set a featured image that best represents your project.

View File

@@ -1,3 +1,3 @@
## Unrelated Gallery Images
Per section 5.5 of %RULES%, any images in your project's Gallery must be relevant to the project and also include a Title.
Per section 5.5 of %RULES%, any images in your project's %PROJECT_GALLERY_FLINK% must be relevant to the project and also include a Title.

View File

@@ -2,4 +2,4 @@ Unfortunately, the Moderation team is unable to assist with your issue.
The reporting system is exclusively for reporting issues to Moderation staff; only violations of [Modrinth's Content Rules](https://modrinth.com/legal/rules) should be reported.
Please visit the [Modrinth Help Center](https://support.modrinth.com/) and click the green bubble to contact support so our support agents can better assist you.
Please visit the [Modrinth Help Center](https://support.modrinth.com/) and click the blue bubble to contact support so our support agents can better assist you.

View File

@@ -1,5 +0,0 @@
## Environment Metadata
Per section 5.1 of %RULES%, it is important that the metadata of your projects is accurate, including %PROJECT_ENVIRONMENT_FLINK%.
We've recently overhauled how environment metadata works on Modrinth, please [Read this blogpost](%NEW_ENVIRONMENTS_LINK%) for full details and information on how to ensure your project is labeled correctly.

View File

@@ -1,5 +0,0 @@
## Environment Metadata
Per section 5.1 of %RULES%, it is important that the metadata of your projects is accurate, including %PROJECT_ENVIRONMENT_FLINK%.
We've recently overhauled how environment metadata works on Modrinth, please [Read this blogpost](%NEW_ENVIRONMENTS_LINK%) for full details and information on how to ensure your project is labeled correctly.

View File

@@ -0,0 +1,5 @@
## Indefinitely Rejected
A project you uploaded has been found to contain or distribute malicious files, this is strictly prohibited and a violation of [Modrinth's Terms of Use](https://modrinth.com/legal/terms).
Our Moderation team has determined this project, and all projects associated with your account should be rejected indefinitely.
We believe this is the best course of action at this time and ask that you **do not resubmit this project**.

View File

@@ -0,0 +1,7 @@
## Source Code Requested
To ensure the safety of all Modrinth users, we ask that you provide the source code for this project before resubmission so that it can be reviewed by our Moderation Team.
We also ask that you provide the source for any included binary files, as well as detailed build instructions allowing us to verify that the compiled code you are distributing matches the provided source.
We understand that you may not want to publish the source code for this project, so you are welcome to share it privately to the [Modrinth Content Moderation Team](https://github.com/ModrinthModeration) on GitHub.

View File

@@ -0,0 +1,5 @@
## Source Code Requested
To ensure the safety of all Modrinth users, we ask that you provide the source code for this project, steps on how to build it, and the process you used to obfuscate it before resubmission so that it can be reviewed by our Moderation Team.
We understand that you may not want to publish the source code for this project, so you are welcome to share it privately to the [Modrinth Content Moderation Team](https://github.com/ModrinthModeration) on GitHub.

View File

@@ -0,0 +1,5 @@
## Source Code Requested
To ensure the safety of all Modrinth users, we ask that you provide the source code for this project before resubmission so that it can be reviewed by our Moderation Team.
We understand that you may not want to publish the source code for this project, so you are welcome to share it privately to the [Modrinth Content Moderation Team](https://github.com/ModrinthModeration) on GitHub.

View File

@@ -0,0 +1,7 @@
## Description Clarity
Per section 2 of [Modrinth's Content Rules](https://modrinth.com/legal/rules) It's important that your Description accurately and honestly represents the content of your project.
Currently, some elements in your Description may be confusing or misleading.
Please edit your description to ensure it accurately represents the current functionality of your project.
Avoid making hyperbolic claims that could misrepresent the facts of your project.
Ensure that your Description is accurate and not likely to confuse users.

View File

@@ -1,5 +1,6 @@
import type { Labrinth } from '@modrinth/api-client'
import { PackageOpenIcon } from '@modrinth/assets'
import type { ModerationModpackPermissionApprovalType, Project } from '@modrinth/utils'
import type { ModerationModpackPermissionApprovalType } from '@modrinth/utils'
import type { Stage } from '../types/stage'
@@ -10,7 +11,7 @@ export default {
// Replace me please.
guidance_url:
'https://www.notion.so/Content-Moderation-Cheat-Sheets-22d5ee711bf081a4920ef08879fe6bf5?source=copy_link#22d5ee711bf08116bd8bc1186f357062',
shouldShow: (project: Project) => project.project_type === 'modpack',
shouldShow: (project: Labrinth.Projects.v2.Project) => project.project_type === 'modpack',
actions: [
{
id: 'button',

View File

@@ -1,4 +1,4 @@
import { defineMessage, useVIntl } from '@vintl/vintl'
import { defineMessage, useVIntl } from '@modrinth/ui'
import type { Nag, NagContext } from '../../types/nags'
@@ -200,7 +200,7 @@ export const coreNags: Nag[] = [
context.project.source_url ||
context.project.wiki_url ||
context.project.discord_url ||
context.project.donation_urls.length > 0
context.project.donation_urls?.length
),
link: {
path: 'settings/links',

View File

@@ -1,5 +1,5 @@
import { defineMessage, useVIntl } from '@modrinth/ui'
import { renderHighlightedString } from '@modrinth/utils'
import { defineMessage, useVIntl } from '@vintl/vintl'
import type { Nag, NagContext } from '../../types/nags'
@@ -235,18 +235,18 @@ export const descriptionNags: Nag[] = [
const summary = context.project.description?.trim() || ''
return Boolean(
summary.match(/https:\/\//g) ||
summary.match(/http:\/\//g) ||
summary.match(/# .*/g) ||
summary.match(/---/g) ||
summary.match(/\n/g) ||
summary.match(/\[.*\]\(.*\)/g) ||
summary.match(/!\[.*\]/g) ||
summary.match(/`.*`/g) ||
summary.match(/\*.*\*/g) ||
summary.match(/_.*_/g) ||
summary.match(/~~.*~~/g) ||
summary.match(/```/g) ||
summary.match(/> /g),
summary.match(/http:\/\//g) ||
summary.match(/# .*/g) ||
summary.match(/---/g) ||
summary.match(/\n/g) ||
summary.match(/\[.*\]\(.*\)/g) ||
summary.match(/!\[.*\]/g) ||
summary.match(/`.*`/g) ||
summary.match(/\*.*\*/g) ||
summary.match(/_.*_/g) ||
summary.match(/~~.*~~/g) ||
summary.match(/```/g) ||
summary.match(/> /g),
)
},
link: {

View File

@@ -1,4 +1,4 @@
import { defineMessage, useVIntl } from '@vintl/vintl'
import { defineMessage, useVIntl } from '@modrinth/ui'
import type { Nag, NagContext } from '../../types/nags'

View File

@@ -1,5 +1,5 @@
import type { Project } from '@modrinth/utils'
import { defineMessage, useVIntl } from '@vintl/vintl'
import type { Labrinth } from '@modrinth/api-client'
import { defineMessage, useVIntl } from '@modrinth/ui'
import type { Nag, NagContext } from '../../types/nags'
@@ -8,7 +8,7 @@ const allResolutionTags = ['8x-', '16x', '32x', '48x', '64x', '128x', '256x', '5
const MAX_TAG_COUNT = 8
function getCategories(
project: Project & { actualProjectType: string },
project: Labrinth.Projects.v2.Project & { actualProjectType: string },
tags: {
categories?: {
project_type: string
@@ -120,7 +120,7 @@ export const tagsNags: Nag[] = [
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const categoriesForProjectType = getCategories(
context.project as Project & { actualProjectType: string },
context.project as Labrinth.Projects.v2.Project & { actualProjectType: string },
context.tags,
)
const totalAvailableTags = categoriesForProjectType.length
@@ -139,7 +139,7 @@ export const tagsNags: Nag[] = [
status: 'required',
shouldShow: (context: NagContext) => {
const categoriesForProjectType = getCategories(
context.project as Project & { actualProjectType: string },
context.project as Labrinth.Projects.v2.Project & { actualProjectType: string },
context.tags,
)
const totalSelectedTags =

View File

@@ -0,0 +1,29 @@
import { GlobeIcon } from '@modrinth/assets'
import type { ButtonAction } from '../../../types/actions'
import type { Stage } from '../../../types/stage'
const environmentMultiple: Stage = {
title: "Is the project's environment information accurate?",
id: 'environment',
navigate: '/settings/versions',
icon: GlobeIcon,
guidance_url: 'https://modrinth.com/legal/rules#miscellaneous',
text: async () =>
(await import('../../messages/checklist-text/environment/environment-multiple.md?raw')).default,
shouldShow: (project, projectV3) => (projectV3?.environment?.length ?? 0) !== 1,
actions: [
{
id: 'side_types_inaccurate',
type: 'button',
label: 'Inaccurate',
weight: 800,
suggestedStatus: 'flagged',
severity: 'low',
shouldShow: (project) => project.project_type === 'mod' || project.project_type === 'modpack',
message: async () => (await import('../../messages/environment/inaccurate.md?raw')).default,
} as ButtonAction,
],
}
export default environmentMultiple

View File

@@ -0,0 +1,29 @@
import { GlobeIcon } from '@modrinth/assets'
import type { ButtonAction } from '../../../types/actions'
import type { Stage } from '../../../types/stage'
const environment: Stage = {
title: "Is the project's environment information accurate?",
id: 'environment',
navigate: '/settings/environment',
icon: GlobeIcon,
guidance_url: 'https://modrinth.com/legal/rules#miscellaneous',
text: async () =>
(await import('../../messages/checklist-text/environment/environment.md?raw')).default,
shouldShow: (project, projectV3) => (projectV3?.environment?.length ?? 0) === 1,
actions: [
{
id: 'side_types_inaccurate',
type: 'button',
label: 'Inaccurate',
weight: 800,
suggestedStatus: 'flagged',
severity: 'low',
shouldShow: (project) => project.project_type === 'mod' || project.project_type === 'modpack',
message: async () => (await import('../../messages/environment/inaccurate.md?raw')).default,
} as ButtonAction,
],
}
export default environment

View File

@@ -12,10 +12,10 @@ const links: Stage = {
shouldShow: (project) =>
Boolean(
project.issues_url ||
project.source_url ||
project.wiki_url ||
project.discord_url ||
project.donation_urls.length > 0,
project.source_url ||
project.wiki_url ||
project.discord_url ||
project.donation_urls.length > 0,
),
text: async (project) => {
let text = (await import('../messages/checklist-text/links/base.md?raw')).default

View File

@@ -1,38 +0,0 @@
import { GlobeIcon } from '@modrinth/assets'
import type { ButtonAction } from '../../types/actions'
import type { Stage } from '../../types/stage'
const sideTypes: Stage = {
title: "Is the project's environment information accurate?",
id: 'environment',
icon: GlobeIcon,
guidance_url: 'https://modrinth.com/legal/rules#miscellaneous',
navigate: '/settings/environment',
text: async () => (await import('../messages/checklist-text/side_types.md?raw')).default,
actions: [
{
id: 'side_types_inaccurate_modpack',
type: 'button',
label: 'Inaccurate',
weight: 800,
suggestedStatus: 'flagged',
severity: 'low',
shouldShow: (project) => project.project_type === 'modpack',
message: async () =>
(await import('../messages/side-types/inaccurate-modpack.md?raw')).default,
} as ButtonAction,
{
id: 'side_types_inaccurate_mod',
type: 'button',
label: 'Inaccurate',
weight: 800,
suggestedStatus: 'flagged',
severity: 'low',
shouldShow: (project) => project.project_type === 'mod',
message: async () => (await import('../messages/side-types/inaccurate-mod.md?raw')).default,
} as ButtonAction,
],
}
export default sideTypes

View File

@@ -1,9 +1,9 @@
import type { Labrinth } from '@modrinth/api-client'
import { BookOpenIcon } from '@modrinth/assets'
import type { Project } from '@modrinth/utils'
import type { Stage } from '../../types/stage'
function hasCustomSlug(project: Project): boolean {
function hasCustomSlug(project: Labrinth.Projects.v2.Project): boolean {
return (
project.slug !==
project.title

View File

@@ -8,4 +8,30 @@ export interface TechReviewContext {
reports: Labrinth.TechReview.Internal.FileReport[]
}
export default [] as ReadonlyArray<QuickReply<TechReviewContext>>
export default [
{
label: '⚠️ Unclear/Misleading',
message: async () => (await import('./messages/tech-review/unclear-misleading.md?raw')).default,
private: false,
},
{
label: '📝 Request Source',
message: async () => (await import('./messages/tech-review/request-source.md?raw')).default,
private: false,
},
{
label: '🔒 Request Source (Obf)',
message: async () => (await import('./messages/tech-review/request-source-obf.md?raw')).default,
private: false,
},
{
label: '📦 Request Source (Bin)',
message: async () => (await import('./messages/tech-review/request-source-bin.md?raw')).default,
private: false,
},
{
label: '🚫 Malware',
message: async () => (await import('./messages/tech-review/malware.md?raw')).default,
private: false,
},
] as ReadonlyArray<QuickReply<TechReviewContext>>

View File

@@ -1,6 +1,27 @@
{
"nags.add-description.description": {
"defaultMessage": "يجب تقديم وصف يوضح غرض المشروع ووظيفته بشكل واضح."
},
"nags.add-description.title": {
"defaultMessage": "أضف وصفًا"
},
"nags.add-icon.description": {
"defaultMessage": "إن إضافة أيقونة فريدة وجذابة وذات صلة تجعل مشروعك قابلاً للتعريف وتساعد في إبرازه."
},
"nags.add-icon.title": {
"defaultMessage": "أضف أيقونة"
},
"nags.add-links.description": {
"defaultMessage": "أضف أي روابط ذات صلة تستهدف خارج Modrinth، مثل كود المصدر، أو متعقب المشكلات، أو دعوة Discord."
},
"nags.add-links.title": {
"defaultMessage": "إضافة روابط خارجية"
},
"nags.all-tags-selected.description": {
"defaultMessage": "لقد حددت جميع العلامات المتاحة، هذا يفسد الغرض من العلامات، والتي تهدف إلى مساعدة المستخدمين في العثور على المشاريع ذات الصلة.\nيرجى تحديد العلامات ذات الصلة بمشروعك فقط."
},
"nags.all-tags-selected.title": {
"defaultMessage": "اختر علامة دقيقة"
}
}

View File

@@ -32,8 +32,8 @@
"nags.feature-gallery-image.title": {
"defaultMessage": "Pagpasundayag og galeriyang hulagway"
},
"nags.select-environments.title": {
"defaultMessage": "Pagpili ug mga kalikopan"
"nags.multiple-resolution-tags.title": {
"defaultMessage": "Pilia ang husto nga resolusyon"
},
"nags.select-tags.title": {
"defaultMessage": "Pagpili og mga timailhan"
@@ -41,9 +41,6 @@
"nags.settings.description.title": {
"defaultMessage": "Duawi ang mga himutangan sa paghulagway"
},
"nags.settings.environments.title": {
"defaultMessage": "Duawi ang mga himutangan sa kalikopan"
},
"nags.settings.license.title": {
"defaultMessage": "Duawi ang mga himutangan sa lisensiya"
},
@@ -57,13 +54,13 @@
"defaultMessage": "Duawi ang tinanan nga mga himutangan"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "Dili mahimong mopareho ang imong kalangkoban sa Ngalan sa imong proyekto. Hinungdanon ang pagbuhat sa mapahibaloon ug madanihon nga Kalangkoban."
"defaultMessage": "Dili angay muawat ang imong kalangkoban sa Ngalan sa imong proyekto. Hinungdanon ang pagbuhat sa mapahibaloon ug madanihon nga Kalangkoban."
},
"nags.upload-gallery-image.title": {
"defaultMessage": "Pagsakarga og galeriyang hulagway"
},
"nags.upload-version.title": {
"defaultMessage": "Pagsakarga og bersiyon"
"defaultMessage": "Pagsakarga og hubad"
},
"nags.verify-external-links.title": {
"defaultMessage": "Pamatud-i ang mga panggawas nga katayan"

View File

@@ -104,12 +104,12 @@
"nags.moderator-feedback.description": {
"defaultMessage": "Před opětovným odesláním zkontrolujte a vyřešte všechny připomínky moderátorského týmu."
},
"nags.moderator-feedback.title": {
"defaultMessage": "Zpětná vazba k recenzi"
},
"nags.multiple-resolution-tags.title": {
"defaultMessage": "Vyberte správné rozlišení"
},
"nags.select-environments.title": {
"defaultMessage": "Zvolte prostředí"
},
"nags.select-license.title": {
"defaultMessage": "Vybrat licenci"
},
@@ -122,9 +122,6 @@
"nags.settings.description.title": {
"defaultMessage": "Navštívit nastavení popisu"
},
"nags.settings.environments.title": {
"defaultMessage": "Navštívit nastavení prostředí"
},
"nags.settings.license.title": {
"defaultMessage": "Navštívit nastavení licence"
},
@@ -152,12 +149,18 @@
"nags.summary-too-short.title": {
"defaultMessage": "Rozšířte shrnutí"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "Udržování mít jméno svého projektu čisté usnadňuje si ho zapamatovat a vyhledat. Verze a informace o modovim načítači se automaticky zobrazují vedle vašeho projektu."
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "Vyčistěte název"
},
"nags.too-many-tags.title": {
"defaultMessage": "Vyberte odpovídající tagy"
},
"nags.upload-gallery-image.description": {
"defaultMessage": "Alespoň jeden obraz s galerie je potřeba na zobrazení obsahu vašeho {type, select, resourcepack {balíčků textur, kromě zvukových a lokalizačních balíčků. Jistly tohle váš balíček popisuje, tak zvolte ten vhodný tag} shader {shader} other {project}}."
},
"nags.upload-gallery-image.title": {
"defaultMessage": "Nahrání obrázku do galerie"
},

Some files were not shown because too many files have changed in this diff Show More