Merge tag 'v0.10.27' into beta
@@ -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.
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
21
packages/api-client/src/core/abstract-upload-client.ts
Normal 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>
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
149
packages/api-client/src/features/node-auth.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
56
packages/api-client/src/modules/archon/content/v0.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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': '',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
154
packages/api-client/src/platform/xhr-upload-client.ts
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
45
packages/api-client/src/state/node-auth.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
88
packages/api-client/src/types/upload.ts
Normal 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
|
||||
}
|
||||
20
packages/api-client/src/utils/jwt-retry.ts
Normal 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
|
||||
}
|
||||
}
|
||||
12
packages/app-lib/.sqlx/query-175067f04e775f5469146f3cb77c422c3ab7203409083fd3c9c968b00b46918f.json
generated
Normal 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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
9
packages/app-lib/COPYING.md
Normal 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.
|
||||
@@ -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"] }
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Add migration script here
|
||||
ALTER TABLE settings ADD COLUMN locale TEXT NOT NULL DEFAULT 'en-US';
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
19
packages/assets/COPYING.md
Normal 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/\*
|
||||
213
packages/assets/build/add-icons.ts
Normal 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()
|
||||
@@ -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
@@ -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 |
@@ -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
|
||||
|
||||
16
packages/assets/icons/arrow-left.svg
Normal 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 |
16
packages/assets/icons/blend.svg
Normal 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 |
26
packages/assets/icons/boxes.svg
Normal 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 |
5
packages/assets/icons/chevron-up.svg
Normal 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 |
20
packages/assets/icons/database-backup.svg
Normal 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 |
18
packages/assets/icons/file-plus.svg
Normal 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 |
14
packages/assets/icons/folder-cog.svg
Normal 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 |
17
packages/assets/icons/layout-template.svg
Normal 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 |
1
packages/assets/icons/palette.svg
Normal 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 |
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
211
packages/assets/styles/ace.css
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'] {
|
||||
|
||||
@@ -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
@@ -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/\*
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
];
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
**Unique environments:** %PROJECT_V3_ENVIRONMENT_COUNT% \
|
||||
**Environments:** `%PROJECT_V3_ALL_ENVIRONMENTS%`
|
||||
@@ -1,2 +1,4 @@
|
||||
**Environment:** `%PROJECT_V3_ENVIRONMENT_0%`
|
||||
|
||||
**Client:** `%PROJECT_CLIENT_SIDE%` \
|
||||
**Server:** `%PROJECT_SERVER_SIDE%`
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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**.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineMessage, useVIntl } from '@vintl/vintl'
|
||||
import { defineMessage, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import type { Nag, NagContext } from '../../types/nags'
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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>>
|
||||
|
||||
@@ -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": "اختر علامة دقيقة"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||