diff --git a/.gitignore b/.gitignore
index 98b3e2a..3ec8cfc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ Packs_DB
nonpacks/staticfiles/
nonpacks/media/
*.log
+AGENTS/
diff --git a/nonpacks/library/migrations/0006_version_mods_manifest_version_pack_description_and_more.py b/nonpacks/library/migrations/0006_version_mods_manifest_version_pack_description_and_more.py
new file mode 100644
index 0000000..b43c943
--- /dev/null
+++ b/nonpacks/library/migrations/0006_version_mods_manifest_version_pack_description_and_more.py
@@ -0,0 +1,28 @@
+# Generated by Django 6.0.3 on 2026-08-04 04:01
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('library', '0005_remove_version_file_versionfile'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='version',
+ name='mods_manifest',
+ field=models.JSONField(blank=True, default=dict),
+ ),
+ migrations.AddField(
+ model_name='version',
+ name='pack_description',
+ field=models.TextField(blank=True, default=''),
+ ),
+ migrations.AddField(
+ model_name='version',
+ name='pack_format',
+ field=models.IntegerField(blank=True, null=True),
+ ),
+ ]
diff --git a/nonpacks/library/models.py b/nonpacks/library/models.py
index cf87d3a..c70fe78 100644
--- a/nonpacks/library/models.py
+++ b/nonpacks/library/models.py
@@ -206,6 +206,9 @@ class Version(models.Model):
version_name = models.CharField(max_length=64)
changelog = models.TextField(blank=True, default='')
downloads = models.PositiveIntegerField(default=0)
+ pack_format = models.IntegerField(null=True, blank=True)
+ pack_description = models.TextField(blank=True, default='')
+ mods_manifest = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
diff --git a/nonpacks/library/tests.py b/nonpacks/library/tests.py
index 068f986..06ebc19 100644
--- a/nonpacks/library/tests.py
+++ b/nonpacks/library/tests.py
@@ -1,7 +1,10 @@
import io
+import json
+import os
import secrets
import shutil
import tempfile
+import zipfile
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
@@ -964,3 +967,178 @@ class UGCApiTests(UGCMediaTestCase, UGCGatedTestCase):
resp = self.client.get(reverse('library:api_users_autocomplete'), {'q': 'Ali'})
self.assertEqual(resp.status_code, 200)
self.assertJSONEqual(resp.content, [{'username': 'Alice'}])
+
+
+def _zip_bytes(entries):
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, 'w') as zf:
+ for name, content in entries.items():
+ zf.writestr(name, content)
+ return buf.getvalue()
+
+
+def _pack_zip():
+ return _zip_bytes({
+ 'pack.mcmeta': json.dumps({
+ 'pack': {'pack_format': 64, 'description': 'A test pack'},
+ 'animationframework': {'id': 'jakebreath:test'},
+ }),
+ })
+
+
+class UGCZipParsingTests(TestCase):
+ def _write_zip(self, data, suffix='.zip'):
+ with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as f:
+ f.write(data)
+ return f.name
+
+ def test_read_pack_mcmeta(self):
+ from library.zips import read_pack_mcmeta
+ path = self._write_zip(_pack_zip())
+ try:
+ fmt, desc = read_pack_mcmeta(path)
+ finally:
+ os.unlink(path)
+ self.assertEqual(fmt, 64)
+ self.assertEqual(desc, 'A test pack')
+
+ def test_read_pack_mcmeta_missing(self):
+ from library.zips import read_pack_mcmeta
+ path = self._write_zip(_zip_bytes({'assets/x.txt': 'x'}))
+ try:
+ fmt, desc = read_pack_mcmeta(path)
+ finally:
+ os.unlink(path)
+ self.assertIsNone(fmt)
+ self.assertEqual(desc, '')
+
+ def test_parse_mods_folder(self):
+ from library.zips import parse_mods_manifest
+ path = self._write_zip(_zip_bytes({
+ 'minecraft/mods/a.jar': 'x', 'minecraft/mods/b.jar': 'y',
+ 'pack.mcmeta': '{}',
+ }))
+ try:
+ manifest = parse_mods_manifest(path, 'pack.zip')
+ finally:
+ os.unlink(path)
+ self.assertEqual(manifest['source'], 'folder')
+ self.assertEqual([m['name'] for m in manifest['files']], ['a.jar', 'b.jar'])
+
+ def test_parse_mods_mrpack(self):
+ from library.zips import parse_mods_manifest
+ path = self._write_zip(_zip_bytes({
+ 'modrinth.index.json': json.dumps({
+ 'dependencies': [
+ {'project_id': 'abc', 'file_name': 'mod-a.jar', 'dependency_type': 'required'},
+ ],
+ }),
+ }), suffix='.mrpack')
+ try:
+ manifest = parse_mods_manifest(path, 'pack.mrpack')
+ finally:
+ os.unlink(path)
+ self.assertEqual(manifest['source'], 'modrinth')
+ self.assertEqual(manifest['files'][0]['name'], 'mod-a.jar')
+
+ def test_parse_mods_curseforge(self):
+ from library.zips import parse_mods_manifest
+ path = self._write_zip(_zip_bytes({
+ 'manifest.json': json.dumps({
+ 'files': [{'projectID': 123, 'fileID': 456, 'fileName': 'cool-mod.jar'}],
+ }),
+ }))
+ try:
+ manifest = parse_mods_manifest(path, 'pack.zip')
+ finally:
+ os.unlink(path)
+ self.assertEqual(manifest['source'], 'curseforge')
+ self.assertEqual(manifest['files'][0]['name'], 'cool-mod.jar')
+
+
+class UGCCategoryTests(UGCMediaTestCase, UGCGatedTestCase):
+ def setUp(self):
+ User = get_user_model()
+ self.alice = User.objects.create_user(username='Alice', password='pw')
+ UserProfile.objects.get_or_create(user=self.alice)
+
+ def _upload_version(self, name, content, content_type='application/zip'):
+ self.gate()
+ self.client.login(username='Alice', password='pw')
+ resp = self.client.post(
+ reverse('library:api_upload_temp'),
+ {'kind': 'version', 'file': SimpleUploadedFile(name, content, content_type=content_type)},
+ HTTP_X_REQUESTED_WITH='XMLHttpRequest',
+ )
+ self.assertEqual(resp.status_code, 200)
+
+ def _upload_version_form(self, version_name='1.0.0'):
+ resp = self.client.post(
+ reverse('library:version_upload', args=[self.project.slug]),
+ {'version_name': version_name},
+ )
+ self.assertRedirects(resp, reverse('library:project_detail', args=[self.project.slug]))
+
+ def test_non_pack_captures_pack_mcmeta(self):
+ self.project = Project.objects.create(
+ slug='non-pack', title='NoN Pack', category='non_pack', owner=self.alice,
+ )
+ self._upload_version('pack.zip', _pack_zip())
+ self._upload_version_form()
+ version = self.project.versions.first()
+ self.assertEqual(version.pack_format, 64)
+ self.assertEqual(version.pack_description, 'A test pack')
+
+ def test_modpack_captures_mods_manifest(self):
+ self.project = Project.objects.create(
+ slug='modpack', title='Modpack', category='modpack', owner=self.alice,
+ )
+ self._upload_version('pack.zip', _zip_bytes({'minecraft/mods/a.jar': 'x'}))
+ self._upload_version_form()
+ version = self.project.versions.first()
+ self.assertEqual(version.mods_manifest['source'], 'folder')
+ self.assertEqual(version.mods_manifest['files'][0]['name'], 'a.jar')
+
+ def test_guide_uploads_md_and_zip_and_renders(self):
+ self.project = Project.objects.create(
+ slug='guide', title='Guide', category='guide', owner=self.alice,
+ )
+ self._upload_version('README.md', b'# Title\n\nBody text.', content_type='text/markdown')
+ self._upload_version('assets.zip', _zip_bytes({'data/x.json': '{}'}))
+ self._upload_version_form()
+ version = self.project.versions.first()
+ self.assertEqual(
+ set(version.files.values_list('kind', flat=True)), {'markdown', 'release'}
+ )
+ md_vf = version.files.get(kind='markdown')
+ # guide_doc endpoint renders the markdown server-side.
+ self.gate()
+ resp = self.client.get(reverse('library:guide_doc', args=['guide', version.pk, md_vf.file.uuid]))
+ self.assertEqual(resp.status_code, 200)
+ self.assertContains(resp, '
Title
', html=True)
+ # Detail page exposes the guide switcher.
+ resp = self.client.get(reverse('library:project_detail', args=['guide']))
+ self.assertContains(resp, 'guide-switcher')
+ self.assertContains(resp, 'README.md')
+
+ def test_guide_doc_rejects_non_markdown(self):
+ self.project = Project.objects.create(
+ slug='guide', title='Guide', category='guide', owner=self.alice,
+ )
+ self._upload_version('pack.zip', _zip_bytes({'data/x.json': '{}'}))
+ self._upload_version_form()
+ version = self.project.versions.first()
+ vf = version.files.get(kind='release')
+ self.gate()
+ resp = self.client.get(reverse('library:guide_doc', args=['guide', version.pk, vf.file.uuid]))
+ self.assertEqual(resp.status_code, 404)
+
+ def test_regular_category_has_no_extra_metadata(self):
+ self.project = Project.objects.create(
+ slug='mod', title='Mod', category='mod', owner=self.alice,
+ )
+ self._upload_version('pack.zip', _pack_zip())
+ self._upload_version_form()
+ version = self.project.versions.first()
+ self.assertIsNone(version.pack_format)
+ self.assertEqual(version.mods_manifest, {})
diff --git a/nonpacks/library/urls.py b/nonpacks/library/urls.py
index 0705fba..d28e5af 100644
--- a/nonpacks/library/urls.py
+++ b/nonpacks/library/urls.py
@@ -13,6 +13,7 @@ urlpatterns = [
path('packs//versions/upload/', views.version_upload, name='version_upload'),
path('packs//versions//download/', views.version_download, name='version_download'),
path('packs//versions//files//download/', views.version_file_download, name='version_file_download'),
+ path('packs//guide///', views.guide_doc, name='guide_doc'),
path('packs//gallery/upload/', views.asset_upload, name='asset_upload'),
path('packs//gallery//delete/', views.asset_delete, name='asset_delete'),
path('packs//contributors/', views.contributors, name='contributors'),
diff --git a/nonpacks/library/views.py b/nonpacks/library/views.py
index d9c8170..42fa4e2 100644
--- a/nonpacks/library/views.py
+++ b/nonpacks/library/views.py
@@ -13,6 +13,7 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.text import slugify
+from common.markdown import render_markdown
from .forms import ContributorForm, ProjectForm, VersionForm
from .models import (
FileIndex,
@@ -29,6 +30,7 @@ from .models import (
slugify_tag,
)
from .storage import delete_file_index, move_file_index, store_temp_file
+from .zips import parse_mods_manifest, read_pack_mcmeta
def _open_indexed_file(file_index):
@@ -312,16 +314,43 @@ def project_detail(request, slug):
assets = project.assets.select_related('file').all()
tags = project.tag_links.select_related('tag__category').all()
can_edit = project.can_edit(request.user)
+
+ guide_docs = []
+ if project.category == 'guide':
+ latest = versions.first()
+ if latest is not None:
+ guide_docs = [vf for vf in latest.files.all() if vf.is_markdown]
+
context = {
'project': project,
'versions': versions,
'assets': assets,
'tags': tags,
'can_edit': can_edit,
+ 'guide_docs': guide_docs,
}
return render(request, 'library/project_detail.html', context)
+def guide_doc(request, slug, version_id, file_uuid):
+ """Render one markdown guide document server-side (used by the switcher)."""
+ version = get_object_or_404(
+ Version.objects.select_related('project'),
+ pk=version_id, project__slug=slug,
+ )
+ vf = get_object_or_404(
+ version.files.select_related('file'), file__uuid=file_uuid,
+ )
+ if not vf.is_markdown:
+ raise Http404
+ fh = _open_indexed_file(vf.file)
+ try:
+ text = fh.read().decode('utf-8', 'replace')
+ finally:
+ fh.close()
+ return HttpResponse(render_markdown(text), content_type='text/html; charset=utf-8')
+
+
DRAFT_KEYS = (
'title', 'summary', 'category', 'description',
'tags', 'version_name', 'changelog',
@@ -376,13 +405,23 @@ def _classify_version_file(file_index, category):
return 'release'
+def _stored_path(file_index):
+ """Resolve an indexed file's stored_path to an absolute filesystem path."""
+ return Path(settings.MEDIA_ROOT) / file_index.stored_path
+
+
def _finalize_version(project, version_name, changelog, temp_uploads):
- """Adopt pending 'version' temp uploads into a new Version as VersionFiles."""
+ """Adopt pending 'version' temp uploads into a new Version as VersionFiles,
+ capturing category-specific metadata (pack.mcmeta / mods manifest)."""
version = Version.objects.create(
project=project,
version_name=version_name,
changelog=changelog,
)
+ pack_format = None
+ pack_description = ''
+ mods_manifest = {}
+
for temp in temp_uploads:
index = _adopt_temp(temp, project.pk, 'versions', 'version')
VersionFile.objects.create(
@@ -392,6 +431,28 @@ def _finalize_version(project, version_name, changelog, temp_uploads):
)
temp.status = 'used'
temp.save(update_fields=['status'])
+
+ is_zip = (index.original_filename or '').lower().endswith('.zip')
+ if project.category == 'non_pack' and is_zip and pack_format is None:
+ fmt, desc = read_pack_mcmeta(_stored_path(index))
+ if fmt is not None:
+ pack_format = fmt
+ pack_description = desc
+ elif project.category == 'modpack' and is_zip and not mods_manifest:
+ manifest = parse_mods_manifest(_stored_path(index), index.original_filename)
+ if manifest.get('files'):
+ mods_manifest = manifest
+
+ update_fields = []
+ if pack_format is not None:
+ version.pack_format = pack_format
+ version.pack_description = pack_description
+ update_fields += ['pack_format', 'pack_description']
+ if mods_manifest:
+ version.mods_manifest = mods_manifest
+ update_fields.append('mods_manifest')
+ if update_fields:
+ version.save(update_fields=update_fields)
return version
diff --git a/nonpacks/library/zips.py b/nonpacks/library/zips.py
new file mode 100644
index 0000000..e9ae3d8
--- /dev/null
+++ b/nonpacks/library/zips.py
@@ -0,0 +1,100 @@
+"""Zip introspection helpers for category-specific version metadata.
+
+Parsed once at upload time and stored on the Version row, so the detail page
+never re-reads large archives. All functions take the on-disk path of an
+adopted file (under MEDIA_ROOT) and return plain dicts/values.
+"""
+
+import json
+import zipfile
+
+
+def _open_zip(path):
+ """Return a ZipFile for the path (None when it's not a zip)."""
+ try:
+ return zipfile.ZipFile(path)
+ except (zipfile.BadZipFile, OSError):
+ return None
+
+
+def read_pack_mcmeta(path):
+ """Extract the NoN-relevant data from a datapack's pack.mcmeta.
+
+ Returns (pack_format, description) or (None, '').
+ """
+ zf = _open_zip(path)
+ if zf is None:
+ return None, ''
+ try:
+ try:
+ data = json.loads(zf.read('pack.mcmeta').decode('utf-8', 'replace'))
+ except (KeyError, json.JSONDecodeError):
+ return None, ''
+ pack = data.get('pack') or {}
+ try:
+ pack_format = int(pack.get('pack_format'))
+ except (TypeError, ValueError):
+ pack_format = None
+ description = pack.get('description', '') or ''
+ if isinstance(description, (dict, list)):
+ description = json.dumps(description)
+ return pack_format, str(description)
+ finally:
+ zf.close()
+
+
+def parse_mods_manifest(path, filename=''):
+ """Inspect a modpack archive and build a manifest for the Mods tab.
+
+ - Modrinth .mrpack: modrinth.index.json → dependencies
+ - Curseforge modpack: manifest.json → files array
+ - Classic folder layout: minecraft/mods/*.jar
+ Returns a dict: {source, files: [...]}.
+ """
+ zf = _open_zip(path)
+ if zf is None:
+ return {'source': 'unknown', 'files': []}
+
+ try:
+ names = set(zf.namelist())
+
+ if filename.lower().endswith('.mrpack') or 'modrinth.index.json' in names:
+ try:
+ index = json.loads(zf.read('modrinth.index.json').decode('utf-8', 'replace'))
+ except (KeyError, json.JSONDecodeError):
+ index = {}
+ deps = []
+ for dep in index.get('dependencies') or []:
+ deps.append({
+ 'name': dep.get('file_name') or dep.get('project_id', 'unknown'),
+ 'version_id': dep.get('version_id'),
+ 'type': dep.get('dependency_type', ''),
+ })
+ return {'source': 'modrinth', 'files': deps}
+
+ if 'manifest.json' in names:
+ try:
+ manifest = json.loads(zf.read('manifest.json').decode('utf-8', 'replace'))
+ except (KeyError, json.JSONDecodeError):
+ manifest = {}
+ files = []
+ for entry in manifest.get('files') or []:
+ files.append({
+ 'name': entry.get('fileName', entry.get('projectID', 'unknown')),
+ 'project_id': entry.get('projectID'),
+ 'file_id': entry.get('fileID'),
+ })
+ if files:
+ return {'source': 'curseforge', 'files': files}
+
+ mods = []
+ prefix = 'minecraft/mods/'
+ for name in sorted(names):
+ if name.startswith(prefix) and not name.endswith('/'):
+ mods.append({'name': name[len(prefix):]})
+ if mods:
+ return {'source': 'folder', 'files': mods}
+
+ return {'source': 'unknown', 'files': []}
+ finally:
+ zf.close()
diff --git a/nonpacks/static/css/style.css b/nonpacks/static/css/style.css
index fb895bc..ead99f1 100644
--- a/nonpacks/static/css/style.css
+++ b/nonpacks/static/css/style.css
@@ -2773,3 +2773,89 @@ a.deletelink {
.file-caption::placeholder {
color: rgba(255, 255, 255, 0.7);
}
+
+/* ========== Category-specific (Phase 2) ========== */
+.pack-format-badge {
+ display: inline-block;
+ margin-left: 8px;
+ padding: 1px 8px;
+ border-radius: 999px;
+ background: var(--md-sys-color-primary);
+ color: var(--md-sys-color-on-primary);
+ font-size: 0.7rem;
+ font-weight: 600;
+}
+.pack-description {
+ margin: 6px 0 0;
+ font-size: 0.85rem;
+ color: var(--md-sys-color-on-surface-variant);
+}
+
+/* Guide switcher */
+.guide-switcher {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-bottom: 16px;
+ border-bottom: 1px solid var(--md-sys-color-outline);
+ padding-bottom: 10px;
+}
+.guide-switch-btn {
+ background: none;
+ border: 1px solid var(--md-sys-color-outline);
+ border-radius: 999px;
+ color: var(--md-sys-color-on-surface-variant);
+ padding: 5px 12px;
+ font-size: 0.8rem;
+ cursor: pointer;
+}
+.guide-switch-btn:hover {
+ color: var(--md-sys-color-primary);
+ border-color: var(--md-sys-color-primary);
+}
+.guide-switch-btn.active {
+ background: var(--md-sys-color-primary);
+ border-color: var(--md-sys-color-primary);
+ color: var(--md-sys-color-on-primary);
+}
+.guide-content {
+ max-height: 70vh;
+ overflow-y: auto;
+}
+
+/* Mods tab */
+.mods-version {
+ margin-bottom: 16px;
+ padding-bottom: 12px;
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
+}
+.mods-version h3 {
+ margin: 0 0 8px;
+ font-size: 0.95rem;
+}
+.mods-source-badge {
+ display: inline-block;
+ margin-left: 8px;
+ padding: 1px 8px;
+ border-radius: 999px;
+ background: var(--md-sys-color-surface-variant);
+ color: var(--md-sys-color-on-surface-variant);
+ font-size: 0.7rem;
+}
+.mods-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+ gap: 6px;
+}
+.mods-list li {
+ font-size: 0.8rem;
+ padding: 4px 8px;
+ border-radius: 6px;
+ background: var(--md-sys-color-surface-variant);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
diff --git a/nonpacks/templates/library/project_create.html b/nonpacks/templates/library/project_create.html
index 06231a3..a91b099 100644
--- a/nonpacks/templates/library/project_create.html
+++ b/nonpacks/templates/library/project_create.html
@@ -27,11 +27,11 @@
{{ project_form.category }}
-