feat: manage project versions v2 (#5049)

* update add files copy and go to next step on just one file

* rename and reorder stages

* add metadata stage and update details stage

* implement files inside metadata stage

* use regular prettier instead of prettier eslint

* remove changelog stage config

* save button on details stage

* update edit buttons in versions table

* add collapse environment selector

* implement dependencies list in metadata step

* move dependencies into provider

* add suggested dependencies to metadata stage

* pnpm prepr

* fix unused var

* Revert "add collapse environment selector"

This reverts commit f90fabc7a57ff201f26e1b628eeced8e6ef75865.

* hide resource pack loader only when its the only loader

* fix no dependencies for modpack

* add breadcrumbs with hide breadcrumb option

* wider stages

* add proper horizonal scroll breadcrumbs

* fix titles

* handle save version in version page

* remove box shadow

* add notification provider to storybook

* add drop area for versions to drop file right into page

* fix mobile versions table buttons overflowing

* pnpm prepr

* fix drop file opening modal in wrong stage

* implement invalid file for dropping files

* allow horizontal scroll on breadcrumbs

* update infer.js as best as possible

* add create version button uploading version state

* add extractVersionFromFilename for resource pack and datapack

* allow jars for datapack project

* detect multiple loaders when possible

* iris means compatible with optifine too

* infer environment on loader change as well

* add tooltip

* prevent navigate forward when cannot go to next step

* larger breadcrumb click targets

* hide loaders and mc versions stage until files added

* fix max width in header

* fix add files from metadata step jumping steps

* define width in NewModal instead

* disable remove dependency in metadata stage

* switch metadata and details buttons positions

* fix remove button spacing

* do not allow duplicate suggested dependencies

* fix version detection for fabric minecraft version semvar

* better verion number detection based on filename

* show resource pack loader but uneditable

* remove vanilla shader detection

* refactor: break up large infer.js into ts and modules

* remove duplicated types

* add fill missing from file name step

* pnpm prepr

* fix neoforge loader parse failing and not adding neoforge loader

* add missing pack formats

* handle new pack format

* pnpm prepr

* add another regex where it is version in anywhere in filename

* only show resource pack or data pack options for filetype on datapack project

* add redundant zip folder check

* reject RP and DP if has redundant folder

* fix hide stage in breadcrumb

* add snapshot group key in case no release version. brings out 26.1 snapshots

* pnpm prepr

* open in group if has something selected

* fix resource pack loader uneditable if accidentally selected on different project type

* add new environment tags

* add unknown and not applicable environment tags

* pnpm prepr

* use shared constant on labels

* use ref for timeout

* remove console logs

* remove box shadow only for cm-content

* feat: xhr upload + fix wrangler prettierignore

* fix: upload content type fix

* fix dependencies version width

* fix already added dependencies logic

* add changelog minheight

* set progress percentage on button

* add legacy fabric detection logic

* lint

* small update on create version button label

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Truman Gao
2026-01-12 12:41:14 -07:00
committed by GitHub
parent b46f6d0141
commit 61c8cd75cd
64 changed files with 3185 additions and 1709 deletions

View File

@@ -1,514 +0,0 @@
import { parse as parseTOML } from '@ltd/j-toml'
import yaml from 'js-yaml'
import JSZip from 'jszip'
import { satisfies } from 'semver'
export const inferVersionInfo = async function (rawFile, project, gameVersions) {
function versionType(number) {
if (number.includes('alpha')) {
return 'alpha'
} else if (
number.includes('beta') ||
number.match(/[^A-z](rc)[^A-z]/) || // includes `rc`
number.match(/[^A-z](pre)[^A-z]/) // includes `pre`
) {
return 'beta'
} else {
return 'release'
}
}
function getGameVersionsMatchingSemverRange(range, gameVersions) {
if (!range) {
return []
}
const ranges = Array.isArray(range) ? range : [range]
return gameVersions.filter((version) => {
const semverVersion = version.split('.').length === 2 ? `${version}.0` : version // add patch version if missing (e.g. 1.16 -> 1.16.0)
return ranges.some((v) => satisfies(semverVersion, v))
})
}
function getGameVersionsMatchingMavenRange(range, gameVersions) {
if (!range) {
return []
}
const ranges = []
while (range.startsWith('[') || range.startsWith('(')) {
let index = range.indexOf(')')
const index2 = range.indexOf(']')
if (index === -1 || (index2 !== -1 && index2 < index)) {
index = index2
}
if (index === -1) break
ranges.push(range.substring(0, index + 1))
range = range.substring(index + 1).trim()
if (range.startsWith(',')) {
range = range.substring(1).trim()
}
}
if (range) {
ranges.push(range)
}
const LESS_THAN_EQUAL = /^\(,(.*)]$/
const LESS_THAN = /^\(,(.*)\)$/
const EQUAL = /^\[(.*)]$/
const GREATER_THAN_EQUAL = /^\[(.*),\)$/
const GREATER_THAN = /^\((.*),\)$/
const BETWEEN = /^\((.*),(.*)\)$/
const BETWEEN_EQUAL = /^\[(.*),(.*)]$/
const BETWEEN_LESS_THAN_EQUAL = /^\((.*),(.*)]$/
const BETWEEN_GREATER_THAN_EQUAL = /^\[(.*),(.*)\)$/
const semverRanges = []
for (const range of ranges) {
let result
if ((result = range.match(LESS_THAN_EQUAL))) {
semverRanges.push(`<=${result[1]}`)
} else if ((result = range.match(LESS_THAN))) {
semverRanges.push(`<${result[1]}`)
} else if ((result = range.match(EQUAL))) {
semverRanges.push(`${result[1]}`)
} else if ((result = range.match(GREATER_THAN_EQUAL))) {
semverRanges.push(`>=${result[1]}`)
} else if ((result = range.match(GREATER_THAN))) {
semverRanges.push(`>${result[1]}`)
} else if ((result = range.match(BETWEEN))) {
semverRanges.push(`>${result[1]} <${result[2]}`)
} else if ((result = range.match(BETWEEN_EQUAL))) {
semverRanges.push(`>=${result[1]} <=${result[2]}`)
} else if ((result = range.match(BETWEEN_LESS_THAN_EQUAL))) {
semverRanges.push(`>${result[1]} <=${result[2]}`)
} else if ((result = range.match(BETWEEN_GREATER_THAN_EQUAL))) {
semverRanges.push(`>=${result[1]} <${result[2]}`)
}
}
return getGameVersionsMatchingSemverRange(semverRanges, gameVersions)
}
const simplifiedGameVersions = gameVersions
.filter((it) => it.version_type === 'release')
.map((it) => it.version)
const inferFunctions = {
// NeoForge
'META-INF/neoforge.mods.toml': (file) => {
const metadata = parseTOML(file, { joiner: '\n' })
if (!metadata.mods || metadata.mods.length === 0) {
return {}
}
const neoForgeDependency = Object.values(metadata.dependencies)
.flat()
.find((dependency) => dependency.modId === 'neoforge')
if (!neoForgeDependency) {
return {}
}
// https://docs.neoforged.net/docs/gettingstarted/versioning/#neoforge
const mcVersionRange = neoForgeDependency.versionRange
.replace('-beta', '')
.replace(/(\d+)(?:\.(\d+))?(?:\.(\d+)?)?/g, (_match, major, minor) => {
return `1.${major}${minor ? '.' + minor : ''}`
})
const gameVersions = getGameVersionsMatchingMavenRange(mcVersionRange, simplifiedGameVersions)
const versionNum = metadata.mods[0].version
return {
name: `${project.title} ${versionNum}`,
version_number: versionNum,
loaders: ['neoforge'],
version_type: versionType(versionNum),
game_versions: gameVersions,
}
},
// Forge 1.13+
'META-INF/mods.toml': async (file, zip) => {
const metadata = parseTOML(file, { joiner: '\n' })
if (metadata.mods && metadata.mods.length > 0) {
let versionNum = metadata.mods[0].version
// ${file.jarVersion} -> Implementation-Version from manifest
const manifestFile = zip.file('META-INF/MANIFEST.MF')
if (metadata.mods[0].version.includes('${file.jarVersion}') && manifestFile !== null) {
const manifestText = await manifestFile.async('text')
const regex = /Implementation-Version: (.*)$/m
const match = manifestText.match(regex)
if (match) {
versionNum = versionNum.replace('${file.jarVersion}', match[1])
}
}
let gameVersions = []
const mcDependencies = Object.values(metadata.dependencies)
.flat()
.filter((dependency) => dependency.modId === 'minecraft')
if (mcDependencies.length > 0) {
gameVersions = getGameVersionsMatchingMavenRange(
mcDependencies[0].versionRange,
simplifiedGameVersions,
)
}
return {
name: `${project.title} ${versionNum}`,
version_number: versionNum,
version_type: versionType(versionNum),
loaders: ['forge'],
game_versions: gameVersions,
}
} else {
return {}
}
},
// Old Forge
'mcmod.info': (file) => {
const metadata = JSON.parse(file)
return {
name: metadata.version ? `${project.title} ${metadata.version}` : '',
version_number: metadata.version,
version_type: versionType(metadata.version),
loaders: ['forge'],
game_versions: simplifiedGameVersions.filter((version) =>
version.startsWith(metadata.mcversion),
),
}
},
// Fabric
'fabric.mod.json': (file) => {
const metadata = JSON.parse(file)
return {
name: `${project.title} ${metadata.version}`,
version_number: metadata.version,
loaders: ['fabric'],
version_type: versionType(metadata.version),
game_versions: metadata.depends
? getGameVersionsMatchingSemverRange(metadata.depends.minecraft, simplifiedGameVersions)
: [],
}
},
// Quilt
'quilt.mod.json': (file) => {
const metadata = JSON.parse(file)
return {
name: `${project.title} ${metadata.quilt_loader.version}`,
version_number: metadata.quilt_loader.version,
loaders: ['quilt'],
version_type: versionType(metadata.quilt_loader.version),
game_versions: metadata.quilt_loader.depends
? getGameVersionsMatchingSemverRange(
metadata.quilt_loader.depends.find((x) => x.id === 'minecraft')
? metadata.quilt_loader.depends.find((x) => x.id === 'minecraft').versions
: [],
simplifiedGameVersions,
)
: [],
}
},
// Bukkit + Other Forks
'plugin.yml': (file) => {
const metadata = yaml.load(file)
return {
name: `${project.title} ${metadata.version}`,
version_number: metadata.version,
version_type: versionType(metadata.version),
// We don't know which fork of Bukkit users are using
loaders: [],
game_versions: gameVersions
.filter(
(x) => x.version.startsWith(metadata['api-version']) && x.version_type === 'release',
)
.map((x) => x.version),
}
},
// Paper 1.19.3+
'paper-plugin.yml': (file) => {
const metadata = yaml.load(file)
return {
name: `${project.title} ${metadata.version}`,
version_number: metadata.version,
version_type: versionType(metadata.version),
loaders: ['paper'],
game_versions: gameVersions
.filter(
(x) => x.version.startsWith(metadata['api-version']) && x.version_type === 'release',
)
.map((x) => x.version),
}
},
// Bungeecord + Waterfall
'bungee.yml': (file) => {
const metadata = yaml.load(file)
return {
name: `${project.title} ${metadata.version}`,
version_number: metadata.version,
version_type: versionType(metadata.version),
loaders: ['bungeecord'],
}
},
// Velocity
'velocity-plugin.json': (file) => {
const metadata = JSON.parse(file)
return {
name: `${project.title} ${metadata.version}`,
version_number: metadata.version,
version_type: versionType(metadata.version),
loaders: ['velocity'],
}
},
// Modpacks
'modrinth.index.json': (file) => {
const metadata = JSON.parse(file)
const loaders = []
if ('forge' in metadata.dependencies) {
loaders.push('forge')
}
if ('neoforge' in metadata.dependencies) {
loaders.push('neoforge')
}
if ('fabric-loader' in metadata.dependencies) {
loaders.push('fabric')
}
if ('quilt-loader' in metadata.dependencies) {
loaders.push('quilt')
}
return {
name: `${project.title} ${metadata.versionId}`,
version_number: metadata.versionId,
version_type: versionType(metadata.versionId),
loaders,
game_versions: gameVersions
.filter((x) => x.version === metadata.dependencies.minecraft)
.map((x) => x.version),
}
},
// Resource Packs + Data Packs
'pack.mcmeta': (file) => {
const metadata = JSON.parse(file)
function getRange(versionA, versionB) {
const startingIndex = gameVersions.findIndex((x) => x.version === versionA)
const endingIndex = gameVersions.findIndex((x) => x.version === versionB)
const final = []
const filterOnlyRelease = gameVersions[startingIndex].version_type === 'release'
for (let i = startingIndex; i >= endingIndex; i--) {
if (gameVersions[i].version_type === 'release' || !filterOnlyRelease) {
final.push(gameVersions[i].version)
}
}
return final
}
const loaders = []
let newGameVersions = []
if (project.actualProjectType === 'mod') {
loaders.push('datapack')
switch (metadata.pack.pack_format) {
case 4:
newGameVersions = getRange('1.13', '1.14.4')
break
case 5:
newGameVersions = getRange('1.15', '1.16.1')
break
case 6:
newGameVersions = getRange('1.16.2', '1.16.5')
break
case 7:
newGameVersions = getRange('1.17', '1.17.1')
break
case 8:
newGameVersions = getRange('1.18', '1.18.1')
break
case 9:
newGameVersions.push('1.18.2')
break
case 10:
newGameVersions = getRange('1.19', '1.19.3')
break
case 11:
newGameVersions = getRange('23w03a', '23w05a')
break
case 12:
newGameVersions.push('1.19.4')
break
default:
}
}
if (project.actualProjectType === 'resourcepack') {
loaders.push('minecraft')
switch (metadata.pack.pack_format) {
case 1:
newGameVersions = getRange('1.6.1', '1.8.9')
break
case 2:
newGameVersions = getRange('1.9', '1.10.2')
break
case 3:
newGameVersions = getRange('1.11', '1.12.2')
break
case 4:
newGameVersions = getRange('1.13', '1.14.4')
break
case 5:
newGameVersions = getRange('1.15', '1.16.1')
break
case 6:
newGameVersions = getRange('1.16.2', '1.16.5')
break
case 7:
newGameVersions = getRange('1.17', '1.17.1')
break
case 8:
newGameVersions = getRange('1.18', '1.18.2')
break
case 9:
newGameVersions = getRange('1.19', '1.19.2')
break
case 11:
newGameVersions = getRange('22w42a', '22w44a')
break
case 12:
newGameVersions.push('1.19.3')
break
case 13:
newGameVersions.push('1.19.4')
break
case 14:
newGameVersions = getRange('23w14a', '23w16a')
break
case 15:
newGameVersions = getRange('1.20', '1.20.1')
break
case 16:
newGameVersions.push('23w31a')
break
case 17:
newGameVersions = getRange('23w32a', '1.20.2-pre1')
break
case 18:
newGameVersions.push('1.20.2')
break
case 19:
newGameVersions.push('23w42a')
break
case 20:
newGameVersions = getRange('23w43a', '23w44a')
break
case 21:
newGameVersions = getRange('23w45a', '23w46a')
break
case 22:
newGameVersions = getRange('1.20.3', '1.20.4')
break
case 24:
newGameVersions = getRange('24w03a', '24w04a')
break
case 25:
newGameVersions = getRange('24w05a', '24w05b')
break
case 26:
newGameVersions = getRange('24w06a', '24w07a')
break
case 28:
newGameVersions = getRange('24w09a', '24w10a')
break
case 29:
newGameVersions.push('24w11a')
break
case 30:
newGameVersions.push('24w12a')
break
case 31:
newGameVersions = getRange('24w13a', '1.20.5-pre3')
break
case 32:
newGameVersions = getRange('1.20.5', '1.20.6')
break
case 33:
newGameVersions = getRange('24w18a', '24w20a')
break
case 34:
newGameVersions = getRange('1.21', '1.21.1')
break
case 35:
newGameVersions.push('24w33a')
break
case 36:
newGameVersions = getRange('24w34a', '24w35a')
break
case 37:
newGameVersions.push('24w36a')
break
case 38:
newGameVersions.push('24w37a')
break
case 39:
newGameVersions = getRange('24w38a', '24w39a')
break
case 40:
newGameVersions.push('24w40a')
break
case 41:
newGameVersions = getRange('1.21.2-pre1', '1.21.2-pre2')
break
case 42:
newGameVersions = getRange('1.21.2', '1.21.3')
break
case 43:
newGameVersions.push('24w44a')
break
case 44:
newGameVersions.push('24w45a')
break
case 45:
newGameVersions.push('24w46a')
break
case 46:
newGameVersions.push('1.21.4')
break
default:
}
}
return {
loaders,
game_versions: newGameVersions,
}
},
}
const zipReader = new JSZip()
const zip = await zipReader.loadAsync(rawFile)
for (const fileName in inferFunctions) {
const file = zip.file(fileName)
if (file !== null) {
const text = await file.async('text')
return inferFunctions[fileName](text, zip)
}
}
}

View File

@@ -0,0 +1,123 @@
// Pack format to Minecraft version mappings
// See: https://minecraft.wiki/w/Pack_format
// NOTE: This needs to be continuously updated as new versions are released.
// Resource pack format history (full table including development versions)
export const RESOURCE_PACK_FORMATS = {
1: { min: '1.6.1', max: '1.8.9' },
2: { min: '1.9', max: '1.10.2' },
3: { min: '1.11', max: '1.12.2' },
4: { min: '1.13', max: '1.14.4' },
5: { min: '1.15', max: '1.16.1' },
6: { min: '1.16.2', max: '1.16.5' },
7: { min: '1.17', max: '1.17.1' },
8: { min: '1.18', max: '1.18.2' },
9: { min: '1.19', max: '1.19.2' },
11: { min: '22w42a', max: '22w44a' },
12: { min: '1.19.3', max: '1.19.3' },
13: { min: '1.19.4', max: '1.19.4' },
14: { min: '23w14a', max: '23w16a' },
15: { min: '1.20', max: '1.20.1' },
16: { min: '23w31a', max: '23w31a' },
17: { min: '23w32a', max: '1.20.2-pre1' },
18: { min: '1.20.2', max: '1.20.2' },
19: { min: '23w42a', max: '23w42a' },
20: { min: '23w43a', max: '23w44a' },
21: { min: '23w45a', max: '23w46a' },
22: { min: '1.20.3', max: '1.20.4' },
24: { min: '24w03a', max: '24w04a' },
25: { min: '24w05a', max: '24w05b' },
26: { min: '24w06a', max: '24w07a' },
28: { min: '24w09a', max: '24w10a' },
29: { min: '24w11a', max: '24w11a' },
30: { min: '24w12a', max: '24w12a' },
31: { min: '24w13a', max: '1.20.5-pre3' },
32: { min: '1.20.5', max: '1.20.6' },
33: { min: '24w18a', max: '24w20a' },
34: { min: '1.21', max: '1.21.1' },
35: { min: '24w33a', max: '24w33a' },
36: { min: '24w34a', max: '24w35a' },
37: { min: '24w36a', max: '24w36a' },
38: { min: '24w37a', max: '24w37a' },
39: { min: '24w38a', max: '24w39a' },
40: { min: '24w40a', max: '24w40a' },
41: { min: '1.21.2-pre1', max: '1.21.2-pre2' },
42: { min: '1.21.2', max: '1.21.3' },
43: { min: '24w44a', max: '24w44a' },
44: { min: '24w45a', max: '24w45a' },
45: { min: '24w46a', max: '24w46a' },
46: { min: '1.21.4', max: '1.21.4' },
55: { min: '1.21.5', max: '1.21.5' },
63: { min: '1.21.6', max: '1.21.6' },
64: { min: '1.21.7', max: '1.21.8' },
69.0: { min: '1.21.9', max: '1.21.10' },
75: { min: '1.21.11', max: '1.21.11' },
} as const
// Data pack format history (full table including development versions)
export const DATA_PACK_FORMATS = {
4: { min: '1.13', max: '1.14.4' },
5: { min: '1.15', max: '1.16.1' },
6: { min: '1.16.2', max: '1.16.5' },
7: { min: '1.17', max: '1.17.1' },
8: { min: '1.18', max: '1.18.1' },
9: { min: '1.18.2', max: '1.18.2' },
10: { min: '1.19', max: '1.19.3' },
11: { min: '23w03a', max: '23w05a' },
12: { min: '1.19.4', max: '1.19.4' },
13: { min: '23w12a', max: '23w14a' },
14: { min: '23w16a', max: '23w17a' },
15: { min: '1.20', max: '1.20.1' },
16: { min: '23w31a', max: '23w31a' },
17: { min: '23w32a', max: '1.20.2-pre1' },
18: { min: '1.20.2', max: '1.20.2' },
19: { min: '23w40a', max: '23w40a' },
20: { min: '23w41a', max: '23w41a' },
21: { min: '23w42a', max: '23w42a' },
22: { min: '23w43a', max: '23w44a' },
23: { min: '23w45a', max: '23w46a' },
24: { min: '1.20.3-pre1', max: '1.20.3-pre1' },
25: { min: '1.20.3-pre2', max: '1.20.3-pre4' },
26: { min: '1.20.3', max: '1.20.4' },
27: { min: '23w51a', max: '23w51b' },
28: { min: '24w03a', max: '24w04a' },
29: { min: '24w05a', max: '24w05b' },
30: { min: '24w06a', max: '24w06a' },
31: { min: '24w07a', max: '24w07a' },
32: { min: '24w09a', max: '24w10a' },
33: { min: '24w11a', max: '24w11a' },
34: { min: '24w12a', max: '24w12a' },
35: { min: '24w13a', max: '24w13a' },
36: { min: '24w14a', max: '24w14a' },
37: { min: '1.20.5-pre1', max: '1.20.5-pre1' },
38: { min: '1.20.5-pre2', max: '1.20.5-pre3' },
39: { min: '1.20.5-pre4', max: '1.20.5-rc3' },
40: { min: '1.20.5-rc4', max: '1.20.5-rc4' },
41: { min: '1.20.5', max: '1.20.6' },
42: { min: '24w18a', max: '24w19b' },
43: { min: '24w20a', max: '24w20a' },
44: { min: '24w21a', max: '24w21b' },
45: { min: '1.21-pre1', max: '1.21-pre1' },
46: { min: '1.21-pre2', max: '1.21-pre4' },
47: { min: '1.21-rc1', max: '1.21-rc1' },
48: { min: '1.21', max: '1.21.1' },
49: { min: '24w33a', max: '24w33a' },
50: { min: '24w34a', max: '24w35a' },
51: { min: '24w36a', max: '24w36a' },
52: { min: '24w37a', max: '24w37a' },
53: { min: '24w38a', max: '24w38a' },
54: { min: '24w39a', max: '24w39a' },
55: { min: '24w40a', max: '24w40a' },
56: { min: '1.21.2-pre1', max: '1.21.2-pre2' },
57: { min: '1.21.2', max: '1.21.3' },
58: { min: '24w44a', max: '24w44a' },
59: { min: '24w45a', max: '24w45a' },
60: { min: '24w46a', max: '24w46a' },
61: { min: '1.21.4', max: '1.21.4' },
71: { min: '1.21.5', max: '1.21.5' },
80: { min: '1.21.6', max: '1.21.6' },
81: { min: '1.21.7', max: '1.21.8' },
88.0: { min: '1.21.9', max: '1.21.10' },
94.1: { min: '1.21.11', max: '1.21.11' },
} as const

View File

@@ -0,0 +1,3 @@
export type { InferredVersionInfo } from './infer'
export { inferVersionInfo } from './infer'
export { extractVersionDetailsFromFilename } from './version-utils'

View File

@@ -0,0 +1,132 @@
import JSZip from 'jszip'
import { createLoaderParsers } from './loader-parsers'
import { createMultiFileDetectors } from './multi-file-detectors'
import { createPackParser } from './pack-parsers'
import { extractVersionDetailsFromFilename } from './version-utils'
export type GameVersion = { version: string; version_type: string }
export type Project = { title: string; actualProjectType?: string }
export type RawFile = File | (Blob & { name: string })
export interface InferredVersionInfo {
name?: string
version_number?: string
version_type?: 'alpha' | 'beta' | 'release'
loaders?: string[]
game_versions?: string[]
}
/**
* Fills in missing version information from the filename if not already present.
*/
function fillMissingFromFilename(
result: InferredVersionInfo,
filename: string,
projectTitle: string,
): InferredVersionInfo {
const filenameDetails = extractVersionDetailsFromFilename(filename)
if (!result.version_number && filenameDetails.versionNumber) {
result.version_number = filenameDetails.versionNumber
}
if (!result.version_type) {
result.version_type = filenameDetails.versionType
}
if (!result.name && result.version_number) {
result.name = `${projectTitle} ${result.version_number}`
}
return result
}
/**
* Main function to infer version information from a file.
* Analyzes mod loaders, packs, and other Minecraft-related file formats.
*/
export const inferVersionInfo = async function (
rawFile: RawFile,
project: Project,
gameVersions: GameVersion[],
): Promise<InferredVersionInfo> {
const simplifiedGameVersions = gameVersions
.filter((it) => it.version_type === 'release')
.map((it) => it.version)
const zipReader = new JSZip()
const zip = await zipReader.loadAsync(rawFile)
const loaderParsers = createLoaderParsers(project, gameVersions, simplifiedGameVersions)
const packParser = createPackParser(project, gameVersions, rawFile)
const multiFileDetectors = createMultiFileDetectors(project, gameVersions, rawFile)
const inferFunctions = {
...loaderParsers,
'pack.mcmeta': packParser,
}
// Multi-loader detection
const multiLoaderFiles = [
'META-INF/neoforge.mods.toml',
'META-INF/mods.toml',
'fabric.mod.json',
'quilt.mod.json',
]
const detectedLoaderFiles = multiLoaderFiles.filter((fileName) => zip.file(fileName) !== null)
if (detectedLoaderFiles.length > 1) {
const results: InferredVersionInfo[] = []
for (const fileName of detectedLoaderFiles) {
const file = zip.file(fileName)
if (file !== null) {
const text = await file.async('text')
const parser = inferFunctions[fileName as keyof typeof inferFunctions]
if (parser) {
const result = await parser(text, zip)
if (result && Object.keys(result).length > 0) results.push(result)
}
}
}
if (results.length > 0) {
const combinedLoaders = [...new Set(results.flatMap((r) => r.loaders || []))]
const allGameVersions = [...new Set(results.flatMap((r) => r.game_versions || []))]
const primaryResult = results.find((r) => r.version_number) || results[0]
const mergedResult = {
name: primaryResult.name,
version_number: primaryResult.version_number,
version_type: primaryResult.version_type,
loaders: combinedLoaders,
game_versions: allGameVersions,
}
return fillMissingFromFilename(mergedResult, rawFile.name, project.title)
}
}
// Standard single-loader detection
for (const fileName in inferFunctions) {
const file = zip.file(fileName)
if (file !== null) {
const text = await file.async('text')
const parser = inferFunctions[fileName as keyof typeof inferFunctions]
if (parser) {
const result = await parser(text, zip)
return fillMissingFromFilename(result, rawFile.name, project.title)
}
}
}
// Multi-file detection functions
for (const detector of Object.values(multiFileDetectors)) {
const result = await detector(zip)
if (result !== null) {
return fillMissingFromFilename(result, rawFile.name, project.title)
}
}
return fillMissingFromFilename({}, rawFile.name, project.title)
}

View File

@@ -0,0 +1,268 @@
import { parse as parseTOML } from '@ltd/j-toml'
import yaml from 'js-yaml'
import type JSZip from 'jszip'
import type { GameVersion, InferredVersionInfo, Project } from './infer'
import {
getGameVersionsMatchingMavenRange,
getGameVersionsMatchingSemverRange,
} from './version-ranges'
import { versionType } from './version-utils'
/**
* Creates the inferFunctions object containing all mod loader parsers.
*/
export function createLoaderParsers(
project: Project,
gameVersions: GameVersion[],
simplifiedGameVersions: string[],
) {
return {
// NeoForge
'META-INF/neoforge.mods.toml': (file: string): InferredVersionInfo => {
const metadata = parseTOML(file, { joiner: '\n' }) as any
const versionNum = metadata.mods?.[0]?.version || ''
let newGameVersions: string[] = []
if (metadata.dependencies) {
const neoForgeDependency = Object.values(metadata.dependencies)
.flat()
.find((dependency: any) => dependency.modId === 'neoforge')
if (neoForgeDependency) {
try {
// https://docs.neoforged.net/docs/gettingstarted/versioning/#neoforge
const mcVersionRange = (neoForgeDependency as any).versionRange
.replace('-beta', '')
.replace(
/(\d+)(?:\.(\d+))?(?:\.(\d+)?)?/g,
(_match: string, major: string, minor: string) => {
return `1.${major}${minor ? '.' + minor : ''}`
},
)
newGameVersions = getGameVersionsMatchingMavenRange(
mcVersionRange,
simplifiedGameVersions,
)
} catch {
// Ignore parsing errors, just leave game_versions empty
}
}
}
return {
name: versionNum ? `${project.title} ${versionNum}` : '',
version_number: versionNum,
loaders: ['neoforge'],
version_type: versionType(versionNum),
game_versions: newGameVersions,
}
},
// Forge 1.13+
'META-INF/mods.toml': async (file: string, zip: JSZip): Promise<InferredVersionInfo> => {
const metadata = parseTOML(file, { joiner: '\n' }) as any
if (metadata.mods && metadata.mods.length > 0) {
let versionNum = metadata.mods[0].version
// ${file.jarVersion} -> Implementation-Version from manifest
const manifestFile = zip.file('META-INF/MANIFEST.MF')
if (metadata.mods[0].version.includes('${file.jarVersion}') && manifestFile !== null) {
const manifestText = await manifestFile.async('text')
const regex = /Implementation-Version: (.*)$/m
const match = manifestText.match(regex)
if (match) {
versionNum = versionNum.replace('${file.jarVersion}', match[1])
}
}
let newGameVersions: string[] = []
const mcDependencies = Object.values(metadata.dependencies)
.flat()
.filter((dependency: any) => dependency.modId === 'minecraft')
if (mcDependencies.length > 0) {
newGameVersions = getGameVersionsMatchingMavenRange(
(mcDependencies[0] as any).versionRange,
simplifiedGameVersions,
)
}
return {
name: `${project.title} ${versionNum}`,
version_number: versionNum,
version_type: versionType(versionNum),
loaders: ['forge'],
game_versions: newGameVersions,
}
} else {
return {}
}
},
// Old Forge
'mcmod.info': (file: string): InferredVersionInfo => {
const metadata = JSON.parse(file) as any
return {
name: metadata.version ? `${project.title} ${metadata.version}` : '',
version_number: metadata.version,
version_type: versionType(metadata.version),
loaders: ['forge'],
game_versions: simplifiedGameVersions.filter((version) =>
version.startsWith(metadata.mcversion),
),
}
},
// Fabric
'fabric.mod.json': (file: string): InferredVersionInfo => {
const metadata = JSON.parse(file) as any
const detectedGameVersions = metadata.depends
? getGameVersionsMatchingSemverRange(metadata.depends.minecraft, simplifiedGameVersions)
: []
const loaders: string[] = []
// Detect 1.3-1.13 -> legacy-fabric
const hasLegacyVersions = detectedGameVersions.some((version) => {
const match = version.match(/^1\.(\d+)/)
return match && parseInt(match[1]) >= 3 && parseInt(match[1]) <= 13
})
if (hasLegacyVersions) loaders.push('legacy-fabric')
else loaders.push('fabric')
return {
name: `${project.title} ${metadata.version}`,
version_number: metadata.version,
loaders,
version_type: versionType(metadata.version),
game_versions: detectedGameVersions,
}
},
// Quilt
'quilt.mod.json': (file: string): InferredVersionInfo => {
const metadata = JSON.parse(file) as any
return {
name: `${project.title} ${metadata.quilt_loader.version}`,
version_number: metadata.quilt_loader.version,
loaders: ['quilt'],
version_type: versionType(metadata.quilt_loader.version),
game_versions: metadata.quilt_loader.depends
? getGameVersionsMatchingSemverRange(
metadata.quilt_loader.depends.find((x: any) => x.id === 'minecraft')
? metadata.quilt_loader.depends.find((x: any) => x.id === 'minecraft').versions
: [],
simplifiedGameVersions,
)
: [],
}
},
// Bukkit + Other Forks
'plugin.yml': (file: string): InferredVersionInfo => {
const metadata = yaml.load(file) as any
// Check for Folia support
const loaders = []
if (metadata['folia-supported'] === true) {
loaders.push('folia')
}
// We don't know which fork of Bukkit users are using otherwise
return {
name: `${project.title} ${metadata.version}`,
version_number: metadata.version,
version_type: versionType(metadata.version),
loaders,
game_versions: gameVersions
.filter(
(x) => x.version.startsWith(metadata['api-version']) && x.version_type === 'release',
)
.map((x) => x.version),
}
},
// Paper 1.19.3+
'paper-plugin.yml': (file: string): InferredVersionInfo => {
const metadata = yaml.load(file) as any
return {
name: `${project.title} ${metadata.version}`,
version_number: metadata.version,
version_type: versionType(metadata.version),
loaders: ['paper'],
game_versions: gameVersions
.filter(
(x) => x.version.startsWith(metadata['api-version']) && x.version_type === 'release',
)
.map((x) => x.version),
}
},
// Bungeecord + Waterfall
'bungee.yml': (file: string): InferredVersionInfo => {
const metadata = yaml.load(file) as any
return {
name: `${project.title} ${metadata.version}`,
version_number: metadata.version,
version_type: versionType(metadata.version),
loaders: ['bungeecord'],
}
},
// Velocity
'velocity-plugin.json': (file: string): InferredVersionInfo => {
const metadata = JSON.parse(file) as any
return {
name: `${project.title} ${metadata.version}`,
version_number: metadata.version,
version_type: versionType(metadata.version),
loaders: ['velocity'],
}
},
// Sponge plugin (8+)
'META-INF/sponge_plugins.json': (file: string): InferredVersionInfo => {
const metadata = JSON.parse(file) as any
const plugin = metadata.plugins?.[0]
if (!plugin) {
return {}
}
return {
name: plugin.version ? `${project.title} ${plugin.version}` : '',
version_number: plugin.version,
version_type: versionType(plugin.version),
loaders: ['sponge'],
}
},
// Modpacks
'modrinth.index.json': (file: string): InferredVersionInfo => {
const metadata = JSON.parse(file) as any
const loaders = []
if ('forge' in metadata.dependencies) {
loaders.push('forge')
}
if ('neoforge' in metadata.dependencies) {
loaders.push('neoforge')
}
if ('fabric-loader' in metadata.dependencies) {
loaders.push('fabric')
}
if ('quilt-loader' in metadata.dependencies) {
loaders.push('quilt')
}
return {
name: `${project.title} ${metadata.versionId}`,
version_number: metadata.versionId,
version_type: versionType(metadata.versionId),
loaders,
game_versions: gameVersions
.filter((x) => x.version === metadata.dependencies.minecraft)
.map((x) => x.version),
}
},
}
}

View File

@@ -0,0 +1,131 @@
import type JSZip from 'jszip'
import type { GameVersion, InferredVersionInfo, Project, RawFile } from './infer'
import { extractVersionFromFilename, versionType } from './version-utils'
/**
* Creates multi-file detection functions that scan multiple files in a zip.
*/
export function createMultiFileDetectors(
project: Project,
gameVersions: GameVersion[],
rawFile: RawFile,
) {
return {
// Legacy texture pack (pre-1.6.1)
legacyTexturePack: async (zip: JSZip): Promise<InferredVersionInfo | null> => {
const packTxt = zip.file('pack.txt')
if (!packTxt) return null
// Check for legacy texture pack files/directories
const legacyIndicators = [
'font.txt',
'particles.png',
'achievement/',
'armor/',
'art/',
'environment/',
'font/',
'gui/',
'item/',
'lang/',
'misc/',
'mob/',
'textures/',
'title/',
]
const hasLegacyContent = legacyIndicators.some((indicator) => {
if (indicator.endsWith('/')) {
return zip.file(new RegExp(`^${indicator}`))?.length > 0
}
return zip.file(indicator) !== null
})
if (!hasLegacyContent) return null
// Legacy texture packs are compatible with a1.2.2 to 1.5.2
// We'll return versions from 1.0 to 1.5.2 (as older alpha/beta versions may not be in gameVersions)
const legacyVersions = gameVersions
.filter((v) => {
const version = v.version
// Match 1.0 through 1.5.2
if (version.match(/^1\.[0-4](\.\d+)?$/) || version.match(/^1\.5(\.[0-2])?$/)) {
return true
}
return false
})
.map((v) => v.version)
const versionNum = extractVersionFromFilename(rawFile.name)
return {
name: versionNum ? `${project.title} ${versionNum}` : undefined,
version_number: versionNum || undefined,
version_type: versionType(versionNum),
loaders: ['minecraft'],
game_versions: legacyVersions,
}
},
// Shader pack (OptiFine/Iris)
shaderPack: async (zip: JSZip): Promise<InferredVersionInfo | null> => {
const shadersDir = zip.file(/^shaders\//)
if (!shadersDir || shadersDir.length === 0) return null
const loaders: string[] = []
// Check for Iris-specific features in shaders.properties
const shaderProps = zip.file('shaders/shaders.properties')
if (shaderProps) {
const propsText = await shaderProps.async('text')
if (
propsText.includes('iris.features.required') ||
propsText.includes('iris.features.optional')
) {
loaders.push('iris', 'optifine')
}
}
// If no specific loader detected, it could be OptiFine or Iris
if (loaders.length === 0) {
loaders.push('optifine', 'iris')
}
const versionNum = extractVersionFromFilename(rawFile.name)
return {
name: versionNum ? `${project.title} ${versionNum}` : undefined,
version_number: versionNum || undefined,
version_type: versionType(versionNum),
loaders,
game_versions: [],
}
},
// NilLoader mod
nilLoaderMod: async (zip: JSZip): Promise<InferredVersionInfo | null> => {
const nilModFiles = zip.file(/\.nilmod\.css$/)
if (!nilModFiles || nilModFiles.length === 0) return null
return {
loaders: ['nilloader'],
game_versions: [],
}
},
// Java Agent
javaAgent: async (zip: JSZip): Promise<InferredVersionInfo | null> => {
const manifest = zip.file('META-INF/MANIFEST.MF')
if (!manifest) return null
const manifestText = await manifest.async('text')
if (!manifestText.includes('Premain-Class:')) return null
return {
loaders: ['java-agent'],
game_versions: [],
}
},
}
}

View File

@@ -0,0 +1,266 @@
import type JSZip from 'jszip'
import { DATA_PACK_FORMATS, RESOURCE_PACK_FORMATS } from './constants'
import type { GameVersion, InferredVersionInfo, Project, RawFile } from './infer'
import { extractVersionFromFilename, versionType } from './version-utils'
type PackFormat = number | [number] | [number, number]
/**
* Normalizes a pack format to [major, minor] tuple. See https://minecraft.wiki/w/Pack.mcmeta
* - Single integer: [major, 0] for min, [major, Infinity] for max
* - Array [major]: [major, 0] for min, [major, Infinity] for max
* - Array [major, minor]: returns as-is
*/
function normalizePackFormat(format: PackFormat, isMax: boolean): [number, number] {
if (Array.isArray(format)) {
if (format.length === 1) {
return isMax ? [format[0], Infinity] : [format[0], 0]
}
return [format[0], format[1]]
}
return isMax ? [format, Infinity] : [format, 0]
}
/**
* Compares two pack formats [major, minor].
* Returns: -1 if a < b, 0 if equal, 1 if a > b
*/
function comparePackFormats(a: [number, number], b: [number, number]): number {
if (a[0] !== b[0]) return a[0] - b[0]
return a[1] - b[1]
}
/**
* Checks if a format number falls within the min/max range.
*/
function isFormatInRange(
format: number,
minFormat: [number, number],
maxFormat: [number, number],
): boolean {
// Check if the major version matches
if (format < minFormat[0] || format > maxFormat[0]) {
return false
}
// If major version is exactly min or max, we need to check minor version
// For entries in our map, we treat them as [major, 0]
const formatTuple: [number, number] = [format, 0]
// If the format has a decimal (like 69.0, 88.0), extract it
const formatStr = format.toString()
if (formatStr.includes('.')) {
const [maj, min] = formatStr.split('.').map(Number)
formatTuple[0] = maj
formatTuple[1] = min
}
return (
comparePackFormats(formatTuple, minFormat) >= 0 &&
comparePackFormats(formatTuple, maxFormat) <= 0
)
}
/**
* Helper function to get a range of game versions between two versions.
*/
function getRange(versionA: string, versionB: string, gameVersions: GameVersion[]): string[] {
const startingIndex = gameVersions.findIndex((x) => x.version === versionA)
const endingIndex = gameVersions.findIndex((x) => x.version === versionB)
if (startingIndex === -1 || endingIndex === -1) {
return []
}
const final = []
const filterOnlyRelease = gameVersions[startingIndex]?.version_type === 'release'
for (let i = startingIndex; i >= endingIndex; i--) {
if (gameVersions[i].version_type === 'release' || !filterOnlyRelease) {
final.push(gameVersions[i].version)
}
}
return final
}
/**
* Gets game versions from a single pack format number.
*/
function getVersionsFromPackFormat(
packFormat: number,
formatMap: Record<number, { min: string; max: string }>,
gameVersions: GameVersion[],
): string[] {
const mapping = formatMap[packFormat]
if (!mapping) {
return []
}
return getRange(mapping.min, mapping.max, gameVersions)
}
/**
* Gets game versions from a pack format range (min to max inclusive).
* Supports both integer and [major, minor] format specifications.
*/
function getVersionsFromFormatRange(
minFormat: PackFormat,
maxFormat: PackFormat,
formatMap: Record<number, { min: string; max: string }>,
gameVersions: GameVersion[],
): string[] {
const normalizedMin = normalizePackFormat(minFormat, false)
const normalizedMax = normalizePackFormat(maxFormat, true)
// Get all format numbers from the map that fall within the range
const allVersions: string[] = []
const formatNumbers = Object.keys(formatMap)
.map(Number)
.sort((a, b) => a - b)
for (const format of formatNumbers) {
if (isFormatInRange(format, normalizedMin, normalizedMax)) {
const versions = getVersionsFromPackFormat(format, formatMap, gameVersions)
for (const version of versions) {
if (!allVersions.includes(version)) {
allVersions.push(version)
}
}
}
}
return allVersions
}
/**
* Gets game versions from pack.mcmeta metadata.
* Supports multiple format specifications:
* - min_format + max_format: Can be integers or [major, minor] arrays (since 25w31a)
* - supported_formats: Single int, array of ints, or { min_inclusive, max_inclusive }
* - pack_format: Single format number (legacy)
*/
function getGameVersionsFromPackMeta(
packMeta: any,
formatMap: Record<number, { min: string; max: string }>,
gameVersions: GameVersion[],
): string[] {
const pack = packMeta.pack
if (!pack) return []
// Check for min_format and max_format (25w31a+ format)
// These can be: int (e.g., 82), [int] (e.g., [82]), or [major, minor] (e.g., [88, 0])
if (pack.min_format !== undefined && pack.max_format !== undefined) {
return getVersionsFromFormatRange(pack.min_format, pack.max_format, formatMap, gameVersions)
}
// Check for supported_formats
if (pack.supported_formats !== undefined) {
const formats = pack.supported_formats
// Single integer: major version
if (typeof formats === 'number') {
return getVersionsFromPackFormat(formats, formatMap, gameVersions)
}
// Array of integers or [min, max] range
if (Array.isArray(formats)) {
if (
formats.length === 2 &&
typeof formats[0] === 'number' &&
typeof formats[1] === 'number'
) {
// Could be [major, minor] or [minMajor, maxMajor]
// Based on context, if both are close (within ~50), treat as major version range
// Otherwise, treat as [major, minor]
if (Math.abs(formats[1] - formats[0]) < 50) {
// Likely a major version range like [42, 45]
return getVersionsFromFormatRange(formats[0], formats[1], formatMap, gameVersions)
}
}
// Array of major versions
const allVersions: string[] = []
for (const format of formats) {
if (typeof format === 'number') {
const versions = getVersionsFromPackFormat(format, formatMap, gameVersions)
for (const version of versions) {
if (!allVersions.includes(version)) {
allVersions.push(version)
}
}
}
}
return allVersions
}
// Object format: { min_inclusive, max_inclusive }
if (
typeof formats === 'object' &&
formats.min_inclusive !== undefined &&
formats.max_inclusive !== undefined
) {
return getVersionsFromFormatRange(
formats.min_inclusive,
formats.max_inclusive,
formatMap,
gameVersions,
)
}
}
// Fall back to pack_format (legacy single format)
if (pack.pack_format !== undefined) {
return getVersionsFromPackFormat(pack.pack_format, formatMap, gameVersions)
}
return []
}
/**
* Creates the pack.mcmeta parser function.
*/
export function createPackParser(project: Project, gameVersions: GameVersion[], rawFile: RawFile) {
return async (file: string, zip: JSZip): Promise<InferredVersionInfo> => {
const metadata = JSON.parse(file) as any
// Check for assets/ directory (resource pack) or data/ directory (data pack)
const hasAssetsDir = zip.file(/^assets\//)?.[0] !== undefined
const hasDataDir = zip.file(/^data\//)?.[0] !== undefined
const hasZipExtension = rawFile.name.toLowerCase().endsWith('.zip')
const loaders: string[] = []
let newGameVersions: string[] = []
// Data pack detection: has data/ directory
if (hasDataDir && hasZipExtension) {
loaders.push('datapack')
newGameVersions = getGameVersionsFromPackMeta(metadata, DATA_PACK_FORMATS, gameVersions)
}
// Resource pack detection: has assets/ directory
else if (hasAssetsDir && hasZipExtension) {
loaders.push('minecraft')
newGameVersions = getGameVersionsFromPackMeta(metadata, RESOURCE_PACK_FORMATS, gameVersions)
}
// Fallback to old behavior based on project type
else if (project.actualProjectType === 'mod') {
loaders.push('datapack')
newGameVersions = getGameVersionsFromPackMeta(metadata, DATA_PACK_FORMATS, gameVersions)
} else if (project.actualProjectType === 'resourcepack') {
loaders.push('minecraft')
newGameVersions = getGameVersionsFromPackMeta(metadata, RESOURCE_PACK_FORMATS, gameVersions)
}
// Try to extract version from filename
const versionNum = extractVersionFromFilename(rawFile.name)
return {
name: versionNum ? `${project.title} ${versionNum}` : undefined,
version_number: versionNum || undefined,
version_type: versionType(versionNum),
loaders,
game_versions: newGameVersions,
}
}
}

View File

@@ -0,0 +1,87 @@
import { satisfies } from 'semver'
/**
* Returns game versions that match a semver range or array of ranges.
*/
export function getGameVersionsMatchingSemverRange(
range: string | string[] | undefined,
gameVersions: string[],
): string[] {
if (!range) {
return []
}
const ranges = Array.isArray(range) ? range : [range]
// Normalize ranges: strip trailing hyphens from version numbers used by Fabric for prerelease matching (e.g., ">=1.21.11-" -> ">=1.21.11")
const normalizedRanges = ranges.map((r) => r.replace(/(\d)-(\s|$)/g, '$1$2'))
return gameVersions.filter((version) => {
const semverVersion = version.split('.').length === 2 ? `${version}.0` : version // add patch version if missing (e.g. 1.16 -> 1.16.0)
return normalizedRanges.some((v) => satisfies(semverVersion, v))
})
}
/**
* Returns game versions that match a Maven-style version range.
*/
export function getGameVersionsMatchingMavenRange(
range: string | undefined,
gameVersions: string[],
): string[] {
if (!range) {
return []
}
const ranges = []
while (range.startsWith('[') || range.startsWith('(')) {
let index = range.indexOf(')')
const index2 = range.indexOf(']')
if (index === -1 || (index2 !== -1 && index2 < index)) {
index = index2
}
if (index === -1) break
ranges.push(range.substring(0, index + 1))
range = range.substring(index + 1).trim()
if (range.startsWith(',')) {
range = range.substring(1).trim()
}
}
if (range) {
ranges.push(range)
}
const LESS_THAN_EQUAL = /^\(,(.*)]$/
const LESS_THAN = /^\(,(.*)\)$/
const EQUAL = /^\[(.*)]$/
const GREATER_THAN_EQUAL = /^\[(.*),\)$/
const GREATER_THAN = /^\((.*),\)$/
const BETWEEN = /^\((.*),(.*)\)$/
const BETWEEN_EQUAL = /^\[(.*),(.*)]$/
const BETWEEN_LESS_THAN_EQUAL = /^\((.*),(.*)]$/
const BETWEEN_GREATER_THAN_EQUAL = /^\[(.*),(.*)\)$/
const semverRanges = []
for (const range of ranges) {
let result
if ((result = range.match(LESS_THAN_EQUAL))) {
semverRanges.push(`<=${result[1]}`)
} else if ((result = range.match(LESS_THAN))) {
semverRanges.push(`<${result[1]}`)
} else if ((result = range.match(EQUAL))) {
semverRanges.push(`${result[1]}`)
} else if ((result = range.match(GREATER_THAN_EQUAL))) {
semverRanges.push(`>=${result[1]}`)
} else if ((result = range.match(GREATER_THAN))) {
semverRanges.push(`>${result[1]}`)
} else if ((result = range.match(BETWEEN))) {
semverRanges.push(`>${result[1]} <${result[2]}`)
} else if ((result = range.match(BETWEEN_EQUAL))) {
semverRanges.push(`>=${result[1]} <=${result[2]}`)
} else if ((result = range.match(BETWEEN_LESS_THAN_EQUAL))) {
semverRanges.push(`>${result[1]} <=${result[2]}`)
} else if ((result = range.match(BETWEEN_GREATER_THAN_EQUAL))) {
semverRanges.push(`>=${result[1]} <${result[2]}`)
}
}
return getGameVersionsMatchingSemverRange(semverRanges, gameVersions)
}

View File

@@ -0,0 +1,57 @@
/**
* Determines the version type based on the version string.
*/
export function versionType(number: string | null | undefined): 'alpha' | 'beta' | 'release' {
if (!number) return 'release'
if (number.includes('alpha')) {
return 'alpha'
} else if (
number.includes('beta') ||
number.match(/[^A-z](rc)[^A-z]/) || // includes `rc`
number.match(/[^A-z](pre)[^A-z]/) // includes `pre`
) {
return 'beta'
} else {
return 'release'
}
}
/**
* Extracts version number from a filename.
*/
export function extractVersionFromFilename(filename: string | null | undefined): string | null {
if (!filename) return null
// Remove file extension
let baseName = filename.replace(/\.(zip|jar)$/i, '')
// Remove explicit MC version markers: mc followed by version (e.g., +mc1.21.11, -mc1.21, _mc1.21.4)
baseName = baseName.replace(/[+_-]mc\d+\.\d+(?:\.\d+)?/gi, '')
const versionPatterns = [
/[_\-\s]v(\d+(?:\.\d+)*)/i, // Match version with 'v' anywhere: "Name-v1.2.3-extra" (less strict)
/[_\-\s]r(\d+(?:\.\d+)*)/i, // Match version with 'r' anywhere: "Name-r1.2.3-extra" (less strict)
/[_\-\s](\d+(?:\.\d+)+)$/, // Match version at end after space/separator: "Name 1.2.3"
/(\d+\.\d+(?:\.\d+)*)/, // Match any version pattern x.x or x.x.x.x...: "Name1.2.3extra"
]
for (const pattern of versionPatterns) {
const match = baseName.match(pattern)
if (match && match[1]) {
return match[1]
}
}
return null
}
/**
* Extracts version details from a filename (public API).
*/
export function extractVersionDetailsFromFilename(filename: string | null | undefined) {
const versionNum = extractVersionFromFilename(filename)
return {
versionNumber: versionNum || undefined,
versionType: versionType(versionNum),
}
}