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:
@@ -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>"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user