/delete/', views.asset_delete, name='asset_delete'),
diff --git a/nonpacks/library/views.py b/nonpacks/library/views.py
index 11ca718..caf0eb3 100644
--- a/nonpacks/library/views.py
+++ b/nonpacks/library/views.py
@@ -347,7 +347,38 @@ def project_detail(request, slug):
anim_stats = None
latest = versions.first()
if project.category == 'guide' and latest is not None:
- guide_docs = [vf for vf in latest.files.all() if vf.is_markdown]
+ guide_docs = []
+ seen = set()
+ for vf in latest.files.all():
+ if vf.is_markdown:
+ guide_docs.append({
+ 'filename': vf.filename,
+ 'url': reverse('library:guide_doc', args=[project.slug, latest.pk, vf.file.uuid]),
+ })
+ seen.add((vf.filename or '').lower())
+ release = latest.files.filter(kind='release').first()
+ if release is not None and (release.filename or '').lower().endswith('.zip'):
+ try:
+ with zipfile.ZipFile(_stored_path(release.file)) as zf:
+ members = [
+ m for m in zf.namelist()
+ if not m.endswith('/') and m.lower().endswith('.md')
+ ]
+ except (zipfile.BadZipFile, OSError, FileNotFoundError):
+ members = []
+ members.sort(key=lambda m: (
+ not _guide_norm_path(m).rsplit('/', 1)[-1].startswith(('readme', 'index')),
+ m,
+ ))
+ for m in members:
+ bname = _guide_norm_path(m).rsplit('/', 1)[-1]
+ if bname in seen:
+ continue
+ seen.add(bname)
+ guide_docs.append({
+ 'filename': bname,
+ 'url': reverse('library:guide_zip_doc', args=[project.slug, latest.pk, m]),
+ })
if latest is not None:
animation_manifest = latest.animation_manifest or None
models_manifest = latest.models_manifest or None
@@ -487,6 +518,117 @@ def api_packs_latest(request, namespace, pack_id):
})
+_GUIDE_LINK_RE = re.compile(r'(!?\[[^\]]*\]\()([^\s)]+)([^)]*)(\))')
+_GUIDE_REF_RE = re.compile(r'^(\[[^\]]+\]:\s*)(\S+)\s*$', re.MULTILINE)
+_GUIDE_LINK_SKIP = ('http://', 'https://', '//', 'data:', 'mailto:', '#', 'tel:', 'javascript:')
+
+
+def _guide_norm_path(path):
+ """Normalize a link target / stored path for matching (lowercase, no ./ or /)."""
+ p = (path or '').strip().replace('\\', '/')
+ while p.startswith('./'):
+ p = p[2:]
+ p = p.lstrip('/')
+ return p.lower()
+
+
+def _guide_asset_maps(version, base_dir=None, source='zip'):
+ """Build normalized path/basename -> served URL maps for a guide version.
+
+ ``full_map`` keys are normalized full paths, ``base_map`` keys are
+ normalized basenames. First match wins; priority is: members inside the
+ current doc's own zip directory -> standalone uploaded files -> other zip
+ members. Markdown members map to the rendered-doc endpoints, everything
+ else to the raw file/asset endpoints."""
+ slug = version.project.slug
+ full = {}
+ base = {}
+
+ def _add(path, url):
+ norm = _guide_norm_path(path)
+ full.setdefault(norm, url)
+ bname = norm.rsplit('/', 1)[-1]
+ if bname and bname not in base:
+ base[bname] = url
+
+ release = version.files.filter(kind='release').first()
+ zip_members = []
+ if release is not None and (release.filename or '').lower().endswith('.zip'):
+ try:
+ with zipfile.ZipFile(_stored_path(release.file)) as zf:
+ zip_members = [m for m in zf.namelist() if not m.endswith('/')]
+ except (zipfile.BadZipFile, OSError, FileNotFoundError):
+ zip_members = []
+
+ def _zip_url(member):
+ if member.lower().endswith('.md'):
+ return reverse('library:guide_zip_doc', args=[slug, version.pk, member])
+ return reverse('library:pack_asset', args=[slug, version.pk, member])
+
+ def _under_base(member):
+ if not base_dir:
+ return False
+ norm = _guide_norm_path(member)
+ return norm == base_dir or norm.startswith(base_dir + '/')
+
+ if source == 'zip':
+ for m in zip_members:
+ if _under_base(m):
+ _add(m, _zip_url(m))
+ for vf in version.files.all():
+ name = (vf.filename or '').replace('\\', '/')
+ if not name or (vf.kind == 'release' and name.lower().endswith('.zip')):
+ continue
+ if vf.is_markdown:
+ url = reverse('library:guide_doc', args=[slug, version.pk, vf.file.uuid])
+ else:
+ url = reverse('library:file_request', args=[vf.file.uuid])
+ _add(name, url)
+ for m in zip_members:
+ if not (source == 'zip' and _under_base(m)):
+ _add(m, _zip_url(m))
+ return full, base
+
+
+def _resolve_guide_src(src, full_map, base_map, base_dir=None):
+ """Rewrite one markdown target to its served URL, or None when unknown."""
+ if not src or src.startswith(_GUIDE_LINK_SKIP):
+ return None
+ path = src.split('#')[0].split('?')[0]
+ if not path:
+ return None
+ norm = _guide_norm_path(path)
+ if base_dir:
+ cand = _guide_norm_path(f'{base_dir}/{path}')
+ if cand in full_map:
+ return full_map[cand]
+ if norm in full_map:
+ return full_map[norm]
+ bname = norm.rsplit('/', 1)[-1]
+ if bname in base_map:
+ return base_map[bname]
+ return None
+
+
+def _rewrite_guide_links(md, version, base_dir=None, source='zip'):
+ """Rewrite markdown link/image/reference targets to served URLs whenever a
+ file with the same name is present in the version's uploads (individual
+ files or the release zip). Guide category only."""
+ full_map, base_map = _guide_asset_maps(version, base_dir=base_dir, source=source)
+
+ def _sub_inline(m):
+ new = _resolve_guide_src(m.group(2), full_map, base_map, base_dir)
+ return f'{m.group(1)}{new or m.group(2)}{m.group(3)}{m.group(4)}'
+
+ def _sub_ref(m):
+ new = _resolve_guide_src(m.group(2), full_map, base_map, base_dir)
+ return f'{m.group(1)}{new or m.group(2)}'
+
+ md = _GUIDE_LINK_RE.sub(_sub_inline, md)
+ md = _GUIDE_REF_RE.sub(_sub_ref, md)
+ return md
+
+
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(
@@ -503,6 +645,38 @@ def guide_doc(request, slug, version_id, file_uuid):
text = fh.read().decode('utf-8', 'replace')
finally:
fh.close()
+ if version.project.category == 'guide':
+ text = _rewrite_guide_links(text, version, source='file')
+ return HttpResponse(render_markdown(text), content_type='text/html; charset=utf-8')
+
+
+def guide_zip_doc(request, slug, version_id, asset_path):
+ """Render one markdown document stored inside the version's release zip."""
+ version = get_object_or_404(
+ Version.objects.select_related('project'),
+ pk=version_id, project__slug=slug,
+ )
+ if version.project.category != 'guide':
+ raise Http404
+ if not asset_path or asset_path.startswith('/') or '..' in asset_path.split('/'):
+ raise Http404
+ if not asset_path.lower().endswith('.md'):
+ raise Http404
+ vf = (
+ version.files.select_related('file').filter(kind='release').first()
+ or version.files.select_related('file').first()
+ )
+ if vf is None or not (vf.filename or '').lower().endswith('.zip'):
+ raise Http404
+ with zipfile.ZipFile(_stored_path(vf.file)) as zf:
+ if asset_path not in zf.namelist():
+ raise Http404
+ data = zf.read(asset_path)
+ text = data.decode('utf-8', 'replace')
+ text = _rewrite_guide_links(
+ text, version,
+ base_dir=posixpath.dirname(asset_path), source='zip',
+ )
return HttpResponse(render_markdown(text), content_type='text/html; charset=utf-8')
diff --git a/nonpacks/templates/library/project_detail.html b/nonpacks/templates/library/project_detail.html
index 9155074..015013e 100644
--- a/nonpacks/templates/library/project_detail.html
+++ b/nonpacks/templates/library/project_detail.html
@@ -93,7 +93,7 @@
{% for doc in guide_docs %}
+ data-url="{{ doc.url }}">{{ doc.filename }}
{% endfor %}