From ebd1a1cd1545963b54255c1a618cc0a8935b26f3 Mon Sep 17 00:00:00 2001 From: NeyorDEV <180453958+NeyorDEV@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:11:10 +0000 Subject: [PATCH 1/3] docs: format the README install section and regenerate its site page The README edit on main left a trailing space (Prettier check fails) and the generated installation page out of date (docs:pages check fails), so CI on main was red. --- README.md | 2 +- docs/guide/installation.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0ae2a01..840d7dd 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ https://neyordev.github.io/ModShift/ once GitHub Pages is enabled. ModShift needs **Node.js 20.17+ or 22.13+**. ```bash -# From npm +# From npm npm install --global modshift # From source diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 54f671d..9d911fe 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -5,7 +5,7 @@ ModShift needs **Node.js 20.17+ or 22.13+**. ```bash -# From npm (once published) +# From npm npm install --global modshift # From source From 04a224000c22bbd068132f22d215408ba20398b8 Mon Sep 17 00:00:00 2001 From: NeyorDEV <180453958+NeyorDEV@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:15:04 +0000 Subject: [PATCH 2/3] fix(core): handle resource packs and shaders listed among a pack's mods A real CurseForge export listed Faithful 32x (a resource pack) with its mods; ModShift looked for it under the target's mod loader and reported it as not found. Resource packs and shaders are now looked up under the loaders Modrinth uses for them (minecraft; iris, optifine, canvas, vanilla, per labrinth's validators), installed in resourcepacks/ or shaderpacks/ (also from the CurseForge class id), linked to their own Modrinth page, and never get mod alternatives. --- src/core/contentTypes.ts | 41 +++++++++ src/core/engine.ts | 21 +++-- src/core/loaders.ts | 12 ++- src/core/refs.ts | 4 + src/core/types.ts | 2 + src/curseforge/client.ts | 3 + src/curseforge/resolve.ts | 9 +- src/migrate/plan.ts | 11 ++- src/report/markdown.ts | 3 +- test/core/resourcePacks.test.ts | 149 ++++++++++++++++++++++++++++++++ 10 files changed, 243 insertions(+), 12 deletions(-) create mode 100644 src/core/contentTypes.ts create mode 100644 test/core/resourcePacks.test.ts diff --git a/src/core/contentTypes.ts b/src/core/contentTypes.ts new file mode 100644 index 0000000..2f8a630 --- /dev/null +++ b/src/core/contentTypes.ts @@ -0,0 +1,41 @@ +// Types de contenu d'un pack autres que les mods : packs de ressources et shaders. +// +// Sources (code de Modrinth, validateurs de labrinth) : apps/labrinth/src/validate/resourcepack.rs +// accepte le loader « minecraft » ; shader.rs accepte « optifine » et « iris », « canvas » +// (CanvasShaderValidator) et « vanilla ». Dossiers d'installation : « resourcepacks » (packwiz, +// curseforge.go) et « shaderpacks » (dossier lu par Iris et OptiFine). + +import type { MigrationTarget } from '../types.js'; +import { acceptedLoaders } from './loaders.js'; + +export type ContentType = 'mod' | 'resourcepack' | 'shader'; + +export function contentTypeOf(projectType: string | undefined): ContentType { + return projectType === 'resourcepack' || projectType === 'shader' ? projectType : 'mod'; +} + +// Loaders Modrinth sous lesquels chercher une version pour la cible. Un pack de ressources ne +// dépend pas du loader de mods : seule la version de Minecraft compte. +export function loadersFor(type: ContentType, target: MigrationTarget): string[] { + if (type === 'resourcepack') return ['minecraft']; + if (type === 'shader') return ['iris', 'optifine', 'canvas', 'vanilla']; + return acceptedLoaders(target.loader); +} + +export const CONTENT_FOLDERS: Record = { + mod: 'mods', + resourcepack: 'resourcepacks', + shader: 'shaderpacks', +}; + +// Libellé lisible dans les raisons (« No version for Minecraft 26.3 (resource pack). »). +export const CONTENT_LABELS: Record = { + mod: 'mod', + resourcepack: 'resource pack', + shader: 'shader', +}; + +// Segment d'URL des pages de projet Modrinth (modrinth.com//). +export function modrinthPageUrl(project: { slug: string; projectType?: string }): string { + return `https://modrinth.com/${contentTypeOf(project.projectType)}/${project.slug}`; +} diff --git a/src/core/engine.ts b/src/core/engine.ts index 771b38a..2fbb145 100644 --- a/src/core/engine.ts +++ b/src/core/engine.ts @@ -6,6 +6,7 @@ import type { ModrinthProject, ModrinthVersion } from '../modrinth/types.js'; import type { Loader, MigrationTarget, ModEntry, ModPack } from '../types.js'; import { DEFAULT_CONCURRENCY, mapConcurrent } from '../concurrency.js'; import { findAlternatives } from './alternatives.js'; +import { CONTENT_LABELS, contentTypeOf, loadersFor } from './contentTypes.js'; import { resolveDependencies, type SelectedVersion } from './dependencies.js'; import { acceptedLoaders, @@ -194,11 +195,14 @@ export async function analyzePack( let done = 0; tick(0, distinctProjects.length, distinctProjects[0]?.title ?? 'done'); await mapConcurrent(distinctProjects, concurrency, async (project) => { + // Un pack de ressources ou un shader se cherche sous ses propres loaders, pas sous le loader + // de mods de la cible. + const loaders = loadersFor(contentTypeOf(project.project_type), target); const versions = await api.getProjectVersions(project.id, { - loaders: acceptedLoaders(target.loader), + loaders, gameVersions: [target.minecraftVersion], }); - bestByProject.set(project.id, pickBestVersion(versions, target)); + bestByProject.set(project.id, pickBestVersion(versions, target, loaders)); done++; if (done < distinctProjects.length) tick(done, distinctProjects.length, project.title); }); @@ -212,6 +216,8 @@ export async function analyzePack( pack.mods.flatMap((entry) => { const id = identified.get(entry); if (ignoredEntries.has(entry) || mapped.has(entry) || !id) return []; + // Alternatives et couches de compatibilité ne concernent que les mods. + if (contentTypeOf(id.project.project_type) !== 'mod') return []; if (id.currentVersion && versionSupportsTarget(id.currentVersion, target)) return []; if (bestByProject.get(id.project.id)) return []; return [[id.project.id, id.project] as const]; @@ -302,7 +308,9 @@ export async function analyzePack( currentVersion: current ? toVersionRef(current) : undefined, }; - if (current && versionSupportsTarget(current, target)) { + const contentType = contentTypeOf(id.project.project_type); + const contentLoaders = loadersFor(contentType, target); + if (current && versionSupportsTarget(current, target, contentLoaders)) { const fallback = fallbackCompatibility(current, target); mods.push({ ...common, @@ -318,7 +326,7 @@ export async function analyzePack( const best = bestByProject.get(project.id); if (best) { const fallback = fallbackCompatibility(best, target); - const change = loaderChange(pack.loader, best, target); + const change = contentType === 'mod' ? loaderChange(pack.loader, best, target) : undefined; mods.push({ ...common, status: 'update-found', @@ -339,7 +347,10 @@ export async function analyzePack( const { alternatives, compatLayer } = found; // Une seule fois par projet, même s'il apparaît dans plusieurs fichiers. warnings.push(...found.warnings.splice(0)); - const noVersion = `${idNote}No version for Minecraft ${target.minecraftVersion} / ${target.loader}.`; + const noVersion = + contentType === 'mod' + ? `${idNote}No version for Minecraft ${target.minecraftVersion} / ${target.loader}.` + : `${idNote}No version for Minecraft ${target.minecraftVersion} (${CONTENT_LABELS[contentType]}).`; const adopted = options.adoptAlternatives ? await adoptAlternative(api, alternatives, target, selected) : undefined; diff --git a/src/core/loaders.ts b/src/core/loaders.ts index bc5da18..ad0b890 100644 --- a/src/core/loaders.ts +++ b/src/core/loaders.ts @@ -44,8 +44,13 @@ export function loaderChangeReason(change: LoaderChange): string { return `Same project: its ${to} version replaces the ${from} one (multi-loader mod).`; } -export function versionSupportsTarget(version: ModrinthVersion, target: MigrationTarget): boolean { - const loaders = acceptedLoaders(target.loader); +// `loaders` : loaders acceptés (ceux de la cible par défaut ; « minecraft » pour un pack de +// ressources, voir src/core/contentTypes.ts). +export function versionSupportsTarget( + version: ModrinthVersion, + target: MigrationTarget, + loaders: string[] = acceptedLoaders(target.loader), +): boolean { return ( version.game_versions.includes(target.minecraftVersion) && version.loaders.some((l) => loaders.includes(l)) @@ -59,10 +64,11 @@ const VERSION_TYPE_RANK: Record = { release: 0, beta: 1, al export function pickBestVersion( versions: ModrinthVersion[], target: MigrationTarget, + loaders?: string[], ): ModrinthVersion | undefined { const native = (v: ModrinthVersion) => (v.loaders.includes(target.loader) ? 0 : 1); return versions - .filter((v) => versionSupportsTarget(v, target)) + .filter((v) => versionSupportsTarget(v, target, loaders)) .filter((v) => v.status === undefined || v.status === 'listed' || v.status === 'archived') .sort( (a, b) => diff --git a/src/core/refs.ts b/src/core/refs.ts index ac10cd4..d31e4be 100644 --- a/src/core/refs.ts +++ b/src/core/refs.ts @@ -19,6 +19,10 @@ export function toProjectRef(project: ModrinthProject): ProjectRef { slug: project.slug, title: project.title, categories: project.categories ?? [], + // Seulement pour les packs de ressources, shaders… (page Modrinth et dossier différents). + ...(project.project_type && project.project_type !== 'mod' + ? { projectType: project.project_type } + : {}), }; } diff --git a/src/core/types.ts b/src/core/types.ts index 8d905e1..0927f52 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -25,6 +25,8 @@ export interface ProjectRef { slug: string; title: string; categories: string[]; + // Type de projet Modrinth quand ce n'est pas un mod (« resourcepack », « shader »…). + projectType?: string; } export interface VersionRef { diff --git a/src/curseforge/client.ts b/src/curseforge/client.ts index 9bc8eee..ecaf971 100644 --- a/src/curseforge/client.ts +++ b/src/curseforge/client.ts @@ -34,6 +34,9 @@ export interface CurseForgeMod { name: string; slug: string; summary?: string; + // Classe CurseForge : 6 mod, 12 pack de ressources, 6552 shader, 17 monde (Prism Launcher, + // FlameAPI.cpp ; packwiz, curseforge.go). + classId?: number; links?: { websiteUrl?: string }; } diff --git a/src/curseforge/resolve.ts b/src/curseforge/resolve.ts index 7cf1c73..5a7947c 100644 --- a/src/curseforge/resolve.ts +++ b/src/curseforge/resolve.ts @@ -43,7 +43,7 @@ export async function resolveCurseForgeRefs( } resolved++; ref.resolved = true; - const path = `mods/${file.fileName}`; + const path = `${folderForClass(mod?.classId)}/${file.fileName}`; if (!file.fileName.includes('/') && isSpecSafePath(path)) { entry.fileName = file.fileName; entry.path = path; @@ -55,6 +55,13 @@ export async function resolveCurseForgeRefs( return { resolved, unresolved: refs.length - resolved }; } +// Dossier d'installation d'un fichier selon sa classe CurseForge (voir CurseForgeMod.classId). +function folderForClass(classId: number | undefined): string { + if (classId === 12) return 'resourcepacks'; + if (classId === 6552) return 'shaderpacks'; + return 'mods'; +} + // Avertissement quand des références CurseForge restent non résolues faute de clé. export function missingKeyWarning(pack: ModPack): string | undefined { const count = pack.mods.filter((m) => m.curseforge && !m.curseforge.resolved).length; diff --git a/src/migrate/plan.ts b/src/migrate/plan.ts index fbbdede..bb1ffba 100644 --- a/src/migrate/plan.ts +++ b/src/migrate/plan.ts @@ -1,3 +1,4 @@ +import { CONTENT_FOLDERS, contentTypeOf } from '../core/contentTypes.js'; import { mrpackEnv, type ModSide, neededOn, type TargetSide } from '../core/sides.js'; import type { AnalysisResult, LoaderChange, ModAnalysis, VersionRef } from '../core/types.js'; import { sideLabel } from '../report/labels.js'; @@ -29,6 +30,8 @@ export interface PlannedMod { requiredBy?: string[]; // Mod identifié seulement par son mod id (le fichier d'origine n'est pas connu de Modrinth). unverified?: boolean; + // Type de projet Modrinth quand ce n'est pas un mod (dossier du fichier dans le pack). + projectType?: string; // Version retenue pour un loader de repli (« fallback compatibility, to test »). fallbackLoader?: string; // Même projet, version d'un autre loader (mod multi-loader), ex. { from: 'forge', to: 'fabric' }. @@ -90,10 +93,12 @@ function normalizeEnv(env: ModPack['mods'][number]['env']): MrpackFile['env'] { function fileFromVersion( version: VersionRef, env: MrpackFile['env'], + projectType?: string, ): MrpackFile | { error: string } { const file = version.file; if (!file) return { error: 'the Modrinth version has no file' }; - const path = `mods/${file.fileName}`; + // Packs de ressources et shaders vont dans leur propre dossier, pas dans mods/. + const path = `${CONTENT_FOLDERS[contentTypeOf(projectType)]}/${file.fileName}`; if (!isSpecSafePath(path) || file.fileName.includes('/')) { return { error: `unsafe file name "${file.fileName}"` }; } @@ -156,7 +161,7 @@ export function buildMigrationPlan( // Ajoute un mod s'il n'est pas déjà présent (même projet ou même chemin). const add = (mod: Omit, env: MrpackFile['env']): boolean => { if (projectIds.has(mod.projectId)) return true; - const file = fileFromVersion(mod.version, env); + const file = fileFromVersion(mod.version, env, mod.projectType); if ('error' in file) { warnings.push(`${mod.title}: not included, ${file.error}`); return false; @@ -222,6 +227,7 @@ export function buildMigrationPlan( source: mod.status === 'compatible' ? 'kept' : 'updated', title: mod.project.title, projectId: mod.project.id, + ...(mod.project.projectType ? { projectType: mod.project.projectType } : {}), version: mod.targetVersion, replaces: mod.fileName, ...(mod.identification === 'mod-id' ? { unverified: true } : {}), @@ -266,6 +272,7 @@ export function buildMigrationPlan( source: 'dependency', title: dep.project.title, projectId: dep.project.id, + ...(dep.project.projectType ? { projectType: dep.project.projectType } : {}), version: dep.version, requiredBy: dep.requiredBy, ...(dep.fallback ? { fallbackLoader: dep.fallback.loader } : {}), diff --git a/src/report/markdown.ts b/src/report/markdown.ts index 263779d..2b7a238 100644 --- a/src/report/markdown.ts +++ b/src/report/markdown.ts @@ -1,4 +1,5 @@ import type { AnalysisResult } from '../core/types.js'; +import { modrinthPageUrl } from '../core/contentTypes.js'; import { MOD_STATUSES } from '../core/types.js'; import { CONFLICT_LABELS, @@ -58,7 +59,7 @@ export function renderMarkdown(result: AnalysisResult): string { ['Status', 'Mod', 'File', 'Current', 'Target', 'Notes'], result.mods.map((m) => [ `${STATUS_ICONS[m.status]} ${STATUS_LABELS[m.status]}`, - m.project ? `[${m.project.title}](https://modrinth.com/mod/${m.project.slug})` : m.fileName, + m.project ? `[${m.project.title}](${modrinthPageUrl(m.project)})` : m.fileName, m.fileName, versionLabel(m.currentVersion), versionLabel(m.targetVersion), diff --git a/test/core/resourcePacks.test.ts b/test/core/resourcePacks.test.ts new file mode 100644 index 0000000..447e037 --- /dev/null +++ b/test/core/resourcePacks.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; +import { analyzePack } from '../../src/core/engine.js'; +import type { CurseForgeClient } from '../../src/curseforge/client.js'; +import { resolveCurseForgeRefs } from '../../src/curseforge/resolve.js'; +import { buildMigrationPlan } from '../../src/migrate/plan.js'; +import type { ModrinthVersion } from '../../src/modrinth/types.js'; +import { renderMarkdown } from '../../src/report/markdown.js'; +import type { ModPack } from '../../src/types.js'; +import { createFakeClient, loadCatalog } from '../helpers/fakeModrinth.js'; + +// Cas réel : un export CurseForge listait « Faithful 32x » (pack de ressources) parmi ses mods ; +// ModShift le cherchait sous le loader « fabric » et le déclarait introuvable. +function version( + id: string, + projectId: string, + gameVersion: string, + hash: string, +): ModrinthVersion { + return { + id, + project_id: projectId, + name: id, + version_number: gameVersion, + game_versions: [gameVersion], + // Packs de ressources : loader « minecraft » (labrinth, validate/resourcepack.rs). + loaders: ['minecraft'], + version_type: 'release', + date_published: '2024-09-01T00:00:00Z', + dependencies: [], + status: 'listed', + files: [ + { + hashes: { sha1: hash.repeat(20), sha512: hash.repeat(64) }, + url: `https://cdn.modrinth.com/data/fixture/${id}.zip`, + filename: `${id}.zip`, + primary: true, + size: 1000, + }, + ], + }; +} + +function catalog() { + const base = loadCatalog(); + for (const [id, title] of [ + ['FAITHFL1', 'Faithful Fixture'], + ['OLDPACK1', 'Old Texture Pack'], + ] as const) { + base.projects.push({ + id, + slug: id.toLowerCase(), + title, + description: `${title}, a resource pack.`, + categories: ['16x'], + project_type: 'resourcepack', + loaders: ['minecraft'], + game_versions: ['1.20.1', '1.21.1'], + downloads: 1000, + }); + } + base.versions.push( + version('v1faith', 'FAITHFL1', '1.20.1', 'b6'), + version('v2faith', 'FAITHFL1', '1.21.1', 'b7'), + // Pas de version 1.21.1 : introuvable, sans recherche d'alternatives parmi les mods. + version('v1oldpack', 'OLDPACK1', '1.20.1', 'e8'), + ); + return base; +} + +const entry = (name: string, hash: string) => ({ + fileName: name, + path: `resourcepacks/${name}`, + origin: 'index' as const, + hashes: { sha1: hash.repeat(20) }, + metadata: [], +}); + +const pack = (): ModPack => ({ + source: 'curseforge', + minecraftVersion: '1.20.1', + loader: 'fabric', + mods: [entry('Faithful 32x - 1.20.1.zip', 'b6'), entry('old-pack.zip', 'e8')], + otherFiles: [], + warnings: [], +}); + +describe('resource packs listed among the mods', () => { + it('finds their version under the "minecraft" loader and installs them in resourcepacks/', async () => { + const { client, calls } = createFakeClient(catalog()); + const result = await analyzePack( + pack(), + { minecraftVersion: '1.21.1', loader: 'fabric' }, + client, + ); + const [faithful, old] = result.mods; + expect(faithful).toMatchObject({ + status: 'update-found', + project: { id: 'FAITHFL1', projectType: 'resourcepack' }, + targetVersion: { id: 'v2faith' }, + }); + expect(old).toMatchObject({ + status: 'not-found', + alternatives: [], + reason: 'No version for Minecraft 1.21.1 (resource pack).', + }); + // Aucune recherche d'alternatives (ce serait des mods) pour un pack de ressources. + expect(calls.some((c) => c.url.pathname.endsWith('/search'))).toBe(false); + + const plan = buildMigrationPlan(pack(), result, { loaderVersion: '0.16.5' }); + expect(plan.index.files.map((f) => f.path)).toContain('resourcepacks/v2faith.zip'); + expect(renderMarkdown(result)).toContain( + '[Faithful Fixture](https://modrinth.com/resourcepack/faithfl1)', + ); + }); + + it('puts CurseForge resource packs (class 12) and shaders (class 6552) in their folders', async () => { + const cfPack: ModPack = { + source: 'curseforge', + mods: [1, 2, 3].map((n) => ({ + fileName: `curseforge:${n}:${n}`, + path: `mods/curseforge-${n}`, + origin: 'index' as const, + hashes: {}, + metadata: [], + curseforge: { projectId: n, fileId: n * 10, required: true }, + })), + otherFiles: [], + warnings: [], + }; + const client = { + getFiles: async () => [ + { id: 10, modId: 1, fileName: 'pack.zip', hashes: [] }, + { id: 20, modId: 2, fileName: 'shader.zip', hashes: [] }, + { id: 30, modId: 3, fileName: 'mod.jar', hashes: [] }, + ], + getMods: async () => [ + { id: 1, name: 'Pack', slug: 'pack', classId: 12 }, + { id: 2, name: 'Shader', slug: 'shader', classId: 6552 }, + { id: 3, name: 'Mod', slug: 'mod', classId: 6 }, + ], + } as unknown as CurseForgeClient; + await resolveCurseForgeRefs(cfPack, client); + expect(cfPack.mods.map((m) => m.path)).toEqual([ + 'resourcepacks/pack.zip', + 'shaderpacks/shader.zip', + 'mods/mod.jar', + ]); + }); +}); From 2d158fbb96171ba135a14679feb08674e575f123 Mon Sep 17 00:00:00 2001 From: NeyorDEV <180453958+NeyorDEV@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:15:04 +0000 Subject: [PATCH 3/3] docs(changelog): record the resource pack and shader fix --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1693594..bca880a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ All notable changes to ModShift are documented here. The format follows ## [Unreleased] +### Fixed + +- Resource packs and shaders listed in a pack (e.g. Faithful 32x in a CurseForge export) were + searched as mods for the target loader and reported as not found. They are now looked up + under their own Modrinth loaders, installed in `resourcepacks/` or `shaderpacks/`, linked to + their right Modrinth page, and never get mod alternatives. + ## [0.1.0] - 2026-10-20 First public version.