Add a photo permalink menu (#36)
CI / build-and-scan (push) Successful in 2m36s

Right click or long press on a gallery photo now opens a menu to copy its permalink or share it.
The permalink id is the SHA-256 of the original photo file so it survives reordering and rebuilds.
Each photo also gets a small noindex share page carrying its own link preview image.
Fixed the entrypoint script being checked out with CRLF on Windows by adding a gitattributes file.

Reviewed-on: #36
This commit was merged in pull request #36.
This commit is contained in:
2026-09-23 16:23:13 +02:00
parent f96ce2f8c9
commit 8385453cfd
10 changed files with 613 additions and 1 deletions
+49 -1
View File
@@ -1,6 +1,8 @@
import json
import logging
from html import escape
from pathlib import Path
from urllib.parse import quote
def render_template(template_path, context):
@@ -19,8 +21,11 @@ def render_gallery_images(images):
for img in images:
tags = " ".join(img.get("tags", []))
tag_html = "".join(f'<span class="tag">#{t}</span>' for t in img.get("tags", []))
# data-photo-id is what js/lumeex.js's photo menu builds the permalink from
photo_id = img.get("photo_id")
photo_id_attr = f' data-photo-id="{photo_id}"' if photo_id else ""
html += f"""
<div class="section" data-tags="{tags}">
<div class="section" data-tags="{tags}"{photo_id_attr}>
<div class="tags">{tag_html}</div>
<img class="fade-in-img lazyload" data-src="/img/{img["src"]}" alt="{img.get("alt", "")}" loading="lazy">
</div>
@@ -83,3 +88,46 @@ def generate_sitemap_xml(canonical_url, allowed_paths, output_dir):
with open(output_path, "w", encoding="utf-8") as f:
f.write(sitemap_content)
logging.info(f"[✓] sitemap.xml generated at {output_path}")
def render_photo_page(
photo_id, site_title, description, alt, page_url, image_url, image_size, signature
):
"""
photo/<id>/index.html: the page a copied/shared photo link points at.
Link-preview crawlers read its og:/twitter: tags (without running
scripts) and show this photo; visitors are sent on to the gallery with
?photo=<id>. Kept out of search results with noindex rather than
robots.txt, since preview crawlers such as Twitterbot honour robots.txt
and would then never read the tags.
"""
title = f"{alt} - {site_title}" if alt else site_title
gallery_href = f"/?photo={quote(photo_id)}"
width, height = image_size
t = escape(title)
return f"""<!DOCTYPE html>
{signature}
<html lang='en'>
<head>
<meta charset="utf-8">
<title>{t}</title>
<meta name="robots" content="noindex, follow">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="/favicon.ico" type="image/x-icon">
<meta name="description" content="{escape(description)}">
<meta name="twitter:card" content="summary_large_image">
<meta property="og:type" content="website" />
<meta property="og:site_name" content="{escape(site_title)}" />
<meta property="og:title" content="{t}" />
<meta property="og:description" content="{escape(description)}" />
<meta property="og:url" content="{escape(page_url)}" />
<meta property="og:image" content="{escape(image_url)}" />
<meta property="og:image:width" content="{width}" />
<meta property="og:image:height" content="{height}" />
<meta property="og:image:alt" content="{escape(alt)}" />
<script>location.replace({json.dumps(gallery_href)});</script>
</head>
<body>
<p><a href="{escape(gallery_href)}">{t}</a></p>
</body>
</html>"""
+62
View File
@@ -1,9 +1,13 @@
import hashlib
import io
import logging
from pathlib import Path
from shutil import copyfile
from PIL import Image, features
SHARE_PREVIEW_MAX_WIDTH = 1200
def convert_and_resize_image(input_path, output_path, resize=True, max_width=1140):
"""Convert an image to WebP (or JPEG fallback) and optionally resize it."""
@@ -56,6 +60,64 @@ def process_images(images, resize_images, img_dir, build_dir):
img["src"] = str(Path(img["src"]).with_suffix(".jpg"))
def compute_photo_ids(images, img_dir):
"""
Set each image's permalink id (img["photo_id"]): the SHA-256 of its
original file, so the same photo keeps the same link whatever its
position or filename, across rebuilds. lumeex-web computes the same
value. Duplicates of the same file get "-2", "-3"... in list order.
Must run before process_images, which rewrites img["src"].
"""
seen = {}
for img in images:
src_path = img_dir / img["src"]
if not src_path.exists():
continue
digest = hashlib.sha256(src_path.read_bytes()).hexdigest()
seen[digest] = seen.get(digest, 0) + 1
img["photo_id"] = digest if seen[digest] == 1 else f"{digest}-{seen[digest]}"
def _to_srgb(img, icc_profile):
"""Convert to sRGB when Pillow was built with LittleCMS; unchanged otherwise."""
try:
from PIL import ImageCms
except ImportError:
return img
try:
src_profile = ImageCms.ImageCmsProfile(io.BytesIO(icc_profile))
return ImageCms.profileToProfile(img, src_profile, ImageCms.createProfile("sRGB"))
except ImageCms.PyCMSError as e:
logging.warning(f"[~] Could not convert embedded profile to sRGB: {e}")
return img
def generate_share_preview(input_path, output_path, max_width=SHARE_PREVIEW_MAX_WIDTH):
"""
Link-preview image for one gallery photo (photo/<id>/ pages): a plain
sRGB JPEG no wider than max_width, since WebP and wide-gamut profiles
aren't handled by every messaging app. Returns (width, height), or None
on failure.
"""
try:
img = Image.open(input_path)
icc_profile = img.info.get("icc_profile")
img = img.convert("RGB")
if icc_profile:
img = _to_srgb(img, icc_profile)
width, height = img.size
if width > max_width:
height = int((max_width / width) * height)
width = max_width
img = img.resize((width, height), Image.LANCZOS)
output_path.parent.mkdir(parents=True, exist_ok=True)
img.save(output_path, "JPEG", quality=85)
return width, height
except Exception as e:
logging.error(f"[✗] Error generating share preview for {input_path}: {e}")
return None
def copy_original_images(images, img_dir, build_dir):
"""Copy original image files without processing."""
for img in images:
+38
View File
@@ -11,12 +11,15 @@ from .html_generator import (
generate_robots_txt,
generate_sitemap_xml,
render_gallery_images,
render_photo_page,
render_template,
)
from .image_processor import (
compute_photo_ids,
copy_original_images,
generate_favicon_ico,
generate_favicons_from_logo,
generate_share_preview,
process_images,
)
from .utils import copy_assets, ensure_dir, load_theme_config, load_yaml
@@ -91,6 +94,13 @@ def build():
hero_images = gallery_vars.get("hero", {}).get("images", [])
gallery_images = gallery_vars.get("gallery", {}).get("images", [])
# Permalink ids for gallery photos (not the hero carousel), hashed from
# the original files — before process_images rewrites their src
compute_photo_ids(gallery_images, IMG_DIR)
original_src_by_photo_id = {
img["photo_id"]: img["src"] for img in gallery_images if "photo_id" in img
}
if convert_images:
process_images(hero_images, resize_images, IMG_DIR, BUILD_DIR)
process_images(gallery_images, resize_images, IMG_DIR, BUILD_DIR)
@@ -191,6 +201,34 @@ def build():
else:
logging.warning("[~] No legals section found in site.yaml")
# One link-preview page + preview image per gallery photo. og:image and
# og:url must be absolute for crawlers, hence the canonical URL.
site_info = site_vars.get("info", {})
share_pages = 0
for img in gallery_images:
photo_id = img.get("photo_id")
if not photo_id:
continue
preview_path = BUILD_DIR / "img" / "share" / f"{photo_id}.jpg"
size = generate_share_preview(IMG_DIR / original_src_by_photo_id[photo_id], preview_path)
if not size:
continue
page = render_photo_page(
photo_id,
site_title=site_info.get("title", ""),
description=site_info.get("description", ""),
alt=img.get("alt", ""),
page_url=f"{canonical_url}/photo/{photo_id}/",
image_url=f"{canonical_url}/img/share/{photo_id}.jpg",
image_size=size,
signature=signature,
)
page_path = BUILD_DIR / "photo" / photo_id / "index.html"
page_path.parent.mkdir(parents=True, exist_ok=True)
page_path.write_text(page, encoding="utf-8")
share_pages += 1
logging.info(f"[✓] Photo share pages generated: {share_pages}")
# Hero carrousel generator
if hero_images:
generate_gallery_json_from_images(hero_images, BUILD_DIR)