Author SHA1 Message Date
Djeex 88754bf907 Add a right-click copy link and share menu on gallery photos with stable per-photo permalinks
CI / build-and-scan (pull_request) Successful in 2m9s
2026-09-23 15:46:35 +02:00
Djeex 6c0a169528 Keep shell scripts LF on Windows checkouts and un-ignore .gitattributes 2026-09-23 15:46:35 +02:00
10 changed files with 613 additions and 1 deletions
+2
View File
@@ -0,0 +1,2 @@
# Shell scripts run inside Linux containers: keep LF even on Windows checkouts
*.sh text eol=lf
+1
View File
@@ -2,6 +2,7 @@
!.env !.env
!.sh !.sh
!.gitignore !.gitignore
!.gitattributes
!.gitea !.gitea
!.gitea/** !.gitea/**
output/ output/
+187
View File
@@ -1,6 +1,10 @@
// js for Lumeex // js for Lumeex
// https://git.djeex.fr/Djeex/lumeex // https://git.djeex.fr/Djeex/lumeex
// The site is always served from the domain root (see the /img/ and
// /data/ paths below); the photo menu builds its permalinks from this.
const siteRoot = `${window.location.origin}/`;
// Fade in effect for elements with class 'appear' // Fade in effect for elements with class 'appear'
const setupIntersectionObserver = () => { const setupIntersectionObserver = () => {
document.querySelectorAll('.appear').forEach(parent => { document.querySelectorAll('.appear').forEach(parent => {
@@ -163,6 +167,187 @@ const disableRightClickAndDrag = () => {
document.addEventListener('dragstart', (e) => e.preventDefault()); document.addEventListener('dragstart', (e) => e.preventDefault());
}; };
// Photo menu: right click (or long press on touch screens) on a gallery
// photo opens a small menu to copy its permalink or share it. The
// permalink points at photo/<id>/, a tiny page generated per photo that
// carries the photo's own preview tags for social networks and sends
// visitors back to the gallery with ?photo=<id>. The id is a hash of the
// original photo file, so the link survives reordering and rebuilds.
const PHOTO_MENU_TEXT = {
en: { copy: 'Copy link', share: 'Share…', copied: 'Link copied', copyFailed: 'Could not copy the link' },
fr: { copy: 'Copier le lien', share: 'Partager…', copied: 'Lien copié', copyFailed: 'Impossible de copier le lien' },
};
const PHOTO_MENU_ICONS = {
copy: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.07 0l3-3a5 5 0 0 0-7.07-7.07l-1.5 1.5"/><path d="M14 11a5 5 0 0 0-7.07 0l-3 3a5 5 0 0 0 7.07 7.07l1.5-1.5"/></svg>',
share: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12"/><path d="m7 8 5-5 5 5"/><path d="M5 13v6a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6"/></svg>',
};
const setupPhotoMenu = () => {
const lang = (navigator.language || 'en').toLowerCase().startsWith('fr') ? 'fr' : 'en';
const text = PHOTO_MENU_TEXT[lang];
const canShare = typeof navigator.share === 'function';
let menu = null;
let toast = null;
let toastTimer = null;
let currentUrl = '';
let pressTimer = null;
let pressStart = null;
let openScrollY = 0;
const photoAt = (el) => el.closest && el.closest('.section[data-photo-id] img');
const permalinkFor = (img) => `${siteRoot}photo/${img.closest('.section').dataset.photoId}/`;
const showToast = (message) => {
if (!toast) {
toast = document.createElement('div');
toast.className = 'photo-toast';
toast.setAttribute('role', 'status');
document.body.appendChild(toast);
}
toast.textContent = message;
toast.classList.add('visible');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toast.classList.remove('visible'), 2000);
};
const copyText = async (value) => {
try {
await navigator.clipboard.writeText(value);
return true;
} catch {
// Clipboard API missing (plain http) or refused: fall back to the
// legacy selection-based copy.
const area = document.createElement('textarea');
area.value = value;
area.setAttribute('readonly', '');
area.style.position = 'fixed';
area.style.opacity = '0';
document.body.appendChild(area);
area.select();
let ok = false;
try { ok = document.execCommand('copy'); } catch { ok = false; }
area.remove();
return ok;
}
};
const close = () => {
if (!menu || menu.hidden) return;
menu.hidden = true;
};
const buildMenu = () => {
menu = document.createElement('div');
menu.className = 'photo-menu';
menu.setAttribute('role', 'menu');
menu.tabIndex = -1;
menu.hidden = true;
const actions = canShare ? ['copy', 'share'] : ['copy'];
actions.forEach((action) => {
const button = document.createElement('button');
button.type = 'button';
button.setAttribute('role', 'menuitem');
button.dataset.action = action;
button.innerHTML = PHOTO_MENU_ICONS[action];
button.append(text[action]);
menu.appendChild(button);
});
menu.addEventListener('click', async (e) => {
const button = e.target.closest('button');
if (!button) return;
const url = currentUrl;
close();
if (button.dataset.action === 'copy') {
showToast((await copyText(url)) ? text.copied : text.copyFailed);
} else {
// AbortError just means the visitor dismissed the share sheet.
navigator.share({ title: document.title, url }).catch(() => {});
}
});
menu.addEventListener('keydown', (e) => {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
e.preventDefault();
const items = Array.from(menu.querySelectorAll('button'));
const i = items.indexOf(document.activeElement);
const next = i === -1
? (e.key === 'ArrowDown' ? 0 : items.length - 1)
: (i + (e.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length;
items[next].focus();
});
document.body.appendChild(menu);
};
const open = (img, x, y) => {
if (!menu) buildMenu();
currentUrl = permalinkFor(img);
openScrollY = window.scrollY;
menu.hidden = false;
// Keep the whole menu inside the viewport near the pointer.
const margin = 8;
const left = Math.min(x, window.innerWidth - menu.offsetWidth - margin);
const top = Math.min(y, window.innerHeight - menu.offsetHeight - margin);
menu.style.left = `${Math.max(margin, left)}px`;
menu.style.top = `${Math.max(margin, top)}px`;
// Focus the menu itself (not its first item) so Escape and the arrow
// keys work without a focus ring showing after a mouse right click.
menu.focus({ preventScroll: true });
};
document.addEventListener('contextmenu', (e) => {
const img = photoAt(e.target);
if (img) open(img, e.clientX, e.clientY);
else close();
});
// iOS never fires contextmenu on a long press, so time it by hand.
const cancelPress = () => {
clearTimeout(pressTimer);
pressTimer = null;
};
document.addEventListener('pointerdown', (e) => {
if (menu && !menu.hidden && !menu.contains(e.target)) close();
if (e.pointerType === 'mouse') return;
const img = photoAt(e.target);
if (!img) return;
pressStart = { x: e.clientX, y: e.clientY };
cancelPress();
pressTimer = setTimeout(() => open(img, pressStart.x, pressStart.y), 500);
});
document.addEventListener('pointermove', (e) => {
if (pressTimer && Math.hypot(e.clientX - pressStart.x, e.clientY - pressStart.y) > 10) cancelPress();
});
['pointerup', 'pointercancel'].forEach((type) => document.addEventListener(type, cancelPress));
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') close();
});
// Only a real scroll closes the menu: lazy-loaded photos shifting the
// layout also fire small scroll events (scroll anchoring).
window.addEventListener('scroll', () => {
if (Math.abs(window.scrollY - openScrollY) > 40) close();
}, { passive: true });
window.addEventListener('resize', close);
window.addEventListener('blur', close);
};
// Permalink landing: ?photo=<id> moves that photo to the top of the
// gallery (after the shuffle) and scrolls to it.
const openLinkedPhoto = () => {
const id = new URLSearchParams(window.location.search).get('photo');
if (!id) return;
const gallery = document.querySelector('#gallery');
const section = gallery && Array.from(gallery.querySelectorAll('.section[data-photo-id]'))
.find((el) => el.dataset.photoId === id);
if (!section) return;
gallery.prepend(section);
section.classList.add('photo-linked');
// Wait for the loader to fade out so the scroll lands on the final layout.
window.addEventListener('load', () => {
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
};
// Scroll-to-top button functionality // Scroll-to-top button functionality
const setupScrollToTopButton = () => { const setupScrollToTopButton = () => {
const scrollBtn = document.getElementById("scrollToTop"); const scrollBtn = document.getElementById("scrollToTop");
@@ -190,9 +375,11 @@ document.addEventListener("DOMContentLoaded", () => {
setupIntersectionObserver(); setupIntersectionObserver();
setupLoader(); setupLoader();
shuffleGallery(); shuffleGallery();
openLinkedPhoto();
randomizeHeroBackground(); randomizeHeroBackground();
setupTagFilter(); setupTagFilter();
disableRightClickAndDrag(); disableRightClickAndDrag();
setupPhotoMenu();
setupScrollToTopButton(); setupScrollToTopButton();
fixNavSeparators(); fixNavSeparators();
}); });
+120
View File
@@ -331,6 +331,126 @@ h2 {
margin: 5px 0 60px 0; margin: 5px 0 60px 0;
} }
/* Photo menu (right click / long press on a gallery photo) */
.section[data-photo-id] img {
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
.photo-menu {
position: fixed;
z-index: 1100;
min-width: 200px;
padding: 6px;
background: var(--color-background);
border: 1px solid rgb(0 0 0 / 7%);
border-radius: 14px;
box-shadow: 0 12px 32px rgb(0 0 0 / 16%), 0 2px 6px rgb(0 0 0 / 6%);
font-family: var(--font-primary), Arial, sans-serif;
transform-origin: top left;
animation: photo-menu-in 0.14s ease-out;
}
.photo-menu:focus {
outline: none;
}
.photo-menu[hidden] {
display: none;
}
.photo-menu button {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
border: none;
border-radius: 9px;
background: none;
font: inherit;
font-size: 15px;
color: var(--color-text-dark);
text-align: left;
cursor: pointer;
}
.photo-menu button:hover,
.photo-menu button:focus-visible {
background: rgb(245 245 245);
color: var(--color-primary-dark);
}
.photo-menu button:focus {
outline: none;
}
.photo-menu button:focus-visible {
outline: 2px solid var(--color-secondary);
outline-offset: -2px;
}
.photo-menu svg {
width: 18px;
height: 18px;
flex: none;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.photo-toast {
position: fixed;
left: 50%;
bottom: 24px;
z-index: 1100;
padding: 9px 18px;
border-radius: 30px;
background: var(--color-primary-dark);
color: var(--color-background);
font-family: var(--font-primary), Arial, sans-serif;
font-size: 14px;
white-space: nowrap;
box-shadow: 0 6px 18px rgb(0 0 0 / 18%);
opacity: 0;
transform: translate(-50%, 8px);
transition: opacity 0.2s ease, transform 0.2s ease;
pointer-events: none;
}
.photo-toast.visible {
opacity: 1;
transform: translate(-50%, 0);
}
.section.photo-linked img {
animation: photo-linked 2.4s ease-out 0.6s;
}
@keyframes photo-menu-in {
from { opacity: 0; transform: scale(0.96); }
to { opacity: 1; transform: none; }
}
@keyframes photo-linked {
0%, 60% { box-shadow: 0 0 0 3px var(--color-background), 0 0 0 6px var(--color-secondary); }
100% { box-shadow: 0 0 0 3px transparent, 0 0 0 6px transparent; }
}
@media (prefers-reduced-motion: reduce) {
.photo-menu, .section.photo-linked img {
animation: none;
}
.photo-toast {
transition: none;
}
}
.text-block { .text-block {
padding:10px; padding:10px;
margin:10px; margin:10px;
+49 -1
View File
@@ -1,6 +1,8 @@
import json import json
import logging import logging
from html import escape
from pathlib import Path from pathlib import Path
from urllib.parse import quote
def render_template(template_path, context): def render_template(template_path, context):
@@ -19,8 +21,11 @@ def render_gallery_images(images):
for img in images: for img in images:
tags = " ".join(img.get("tags", [])) tags = " ".join(img.get("tags", []))
tag_html = "".join(f'<span class="tag">#{t}</span>' for t in 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""" html += f"""
<div class="section" data-tags="{tags}"> <div class="section" data-tags="{tags}"{photo_id_attr}>
<div class="tags">{tag_html}</div> <div class="tags">{tag_html}</div>
<img class="fade-in-img lazyload" data-src="/img/{img["src"]}" alt="{img.get("alt", "")}" loading="lazy"> <img class="fade-in-img lazyload" data-src="/img/{img["src"]}" alt="{img.get("alt", "")}" loading="lazy">
</div> </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: with open(output_path, "w", encoding="utf-8") as f:
f.write(sitemap_content) f.write(sitemap_content)
logging.info(f"[✓] sitemap.xml generated at {output_path}") 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 import logging
from pathlib import Path from pathlib import Path
from shutil import copyfile from shutil import copyfile
from PIL import Image, features from PIL import Image, features
SHARE_PREVIEW_MAX_WIDTH = 1200
def convert_and_resize_image(input_path, output_path, resize=True, max_width=1140): 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.""" """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")) 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): def copy_original_images(images, img_dir, build_dir):
"""Copy original image files without processing.""" """Copy original image files without processing."""
for img in images: for img in images:
+38
View File
@@ -11,12 +11,15 @@ from .html_generator import (
generate_robots_txt, generate_robots_txt,
generate_sitemap_xml, generate_sitemap_xml,
render_gallery_images, render_gallery_images,
render_photo_page,
render_template, render_template,
) )
from .image_processor import ( from .image_processor import (
compute_photo_ids,
copy_original_images, copy_original_images,
generate_favicon_ico, generate_favicon_ico,
generate_favicons_from_logo, generate_favicons_from_logo,
generate_share_preview,
process_images, process_images,
) )
from .utils import copy_assets, ensure_dir, load_theme_config, load_yaml 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", []) hero_images = gallery_vars.get("hero", {}).get("images", [])
gallery_images = gallery_vars.get("gallery", {}).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: if convert_images:
process_images(hero_images, resize_images, IMG_DIR, BUILD_DIR) process_images(hero_images, resize_images, IMG_DIR, BUILD_DIR)
process_images(gallery_images, resize_images, IMG_DIR, BUILD_DIR) process_images(gallery_images, resize_images, IMG_DIR, BUILD_DIR)
@@ -191,6 +201,34 @@ def build():
else: else:
logging.warning("[~] No legals section found in site.yaml") 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 # Hero carrousel generator
if hero_images: if hero_images:
generate_gallery_json_from_images(hero_images, BUILD_DIR) generate_gallery_json_from_images(hero_images, BUILD_DIR)
+49
View File
@@ -39,6 +39,55 @@ def test_render_gallery_images_with_and_without_tags():
assert 'alt=""' in html assert 'alt=""' in html
def test_render_gallery_images_adds_photo_id_when_present():
html = hg.render_gallery_images(
[{"src": "gallery/a.jpg", "photo_id": "abc123"}, {"src": "gallery/b.jpg"}]
)
assert html.count("data-photo-id=") == 1
assert 'data-photo-id="abc123"' in html
def _photo_page(**overrides):
kwargs = {
"photo_id": "ab12",
"site_title": "My photos",
"description": "A gallery",
"alt": "Sunset",
"page_url": "https://example.com/photo/ab12/",
"image_url": "https://example.com/img/share/ab12.jpg",
"image_size": (1200, 800),
"signature": "<!-- sig -->",
}
kwargs.update(overrides)
return hg.render_photo_page(**kwargs)
def test_render_photo_page_preview_tags_and_noindex():
page = _photo_page()
assert '<meta name="robots" content="noindex, follow">' in page
assert '<meta property="og:image" content="https://example.com/img/share/ab12.jpg" />' in page
assert '<meta property="og:url" content="https://example.com/photo/ab12/" />' in page
assert '<meta property="og:image:width" content="1200" />' in page
assert "<title>Sunset - My photos</title>" in page
assert 'rel="canonical"' not in page
def test_render_photo_page_redirects_to_gallery():
page = _photo_page()
assert 'location.replace("/?photo=ab12")' in page
assert '<a href="/?photo=ab12">' in page
def test_render_photo_page_escapes_text():
page = _photo_page(alt="", site_title='"><script>alert(1)</script>')
assert "<script>alert(1)" not in page
assert "<title>&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;</title>" in page
def test_generate_gallery_json_from_images(tmp_path): def test_generate_gallery_json_from_images(tmp_path):
images = [{"src": "hero/a.jpg"}, {"src": "hero/b.jpg"}] images = [{"src": "hero/a.jpg"}, {"src": "hero/b.jpg"}]
+76
View File
@@ -135,3 +135,79 @@ def test_generate_favicon_ico_no_favicon_skips(tmp_path):
output_path = tmp_path / "output" / "favicon.ico" output_path = tmp_path / "output" / "favicon.ico"
ip.generate_favicon_ico({}, tmp_path, output_path) ip.generate_favicon_ico({}, tmp_path, output_path)
assert not output_path.exists() assert not output_path.exists()
def test_compute_photo_ids_hashes_original_files(tmp_path, make_image):
import hashlib
img_dir = tmp_path / "photos"
a = make_image(img_dir / "gallery" / "a.jpg", color=(1, 2, 3))
make_image(img_dir / "gallery" / "b.jpg", color=(200, 10, 10))
images = [{"src": "gallery/a.jpg"}, {"src": "gallery/b.jpg"}, {"src": "gallery/missing.jpg"}]
ip.compute_photo_ids(images, img_dir)
assert images[0]["photo_id"] == hashlib.sha256(a.read_bytes()).hexdigest()
assert len(images[1]["photo_id"]) == 64
assert images[0]["photo_id"] != images[1]["photo_id"]
assert "photo_id" not in images[2]
def test_compute_photo_ids_is_independent_of_order_and_name(tmp_path, make_image):
img_dir = tmp_path / "photos"
make_image(img_dir / "gallery" / "a.jpg", color=(1, 2, 3))
(img_dir / "gallery" / "renamed.jpg").write_bytes((img_dir / "gallery" / "a.jpg").read_bytes())
make_image(img_dir / "gallery" / "b.jpg", color=(9, 9, 9))
first = [{"src": "gallery/a.jpg"}, {"src": "gallery/b.jpg"}]
second = [{"src": "gallery/b.jpg"}, {"src": "gallery/renamed.jpg"}]
ip.compute_photo_ids(first, img_dir)
ip.compute_photo_ids(second, img_dir)
assert first[0]["photo_id"] == second[1]["photo_id"]
assert first[1]["photo_id"] == second[0]["photo_id"]
def test_compute_photo_ids_suffixes_duplicates(tmp_path, make_image):
img_dir = tmp_path / "photos"
make_image(img_dir / "gallery" / "a.jpg")
images = [{"src": "gallery/a.jpg"}, {"src": "gallery/a.jpg"}, {"src": "gallery/a.jpg"}]
ip.compute_photo_ids(images, img_dir)
base = images[0]["photo_id"]
assert images[1]["photo_id"] == f"{base}-2"
assert images[2]["photo_id"] == f"{base}-3"
def test_generate_share_preview_resizes_to_jpeg(tmp_path, make_image):
input_path = make_image(tmp_path / "in.png", size=(2400, 1600), fmt="PNG")
output_path = tmp_path / "share" / "x.jpg"
size = ip.generate_share_preview(input_path, output_path)
assert size == (1200, 800)
with Image.open(output_path) as img:
assert img.format == "JPEG"
assert img.size == (1200, 800)
def test_generate_share_preview_converts_embedded_profile_to_srgb(tmp_path):
from PIL import ImageCms
input_path = tmp_path / "in.jpg"
srgb_bytes = ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes()
Image.new("RGB", (300, 200), (10, 200, 30)).save(input_path, "JPEG", icc_profile=srgb_bytes)
output_path = tmp_path / "out.jpg"
size = ip.generate_share_preview(input_path, output_path)
assert size == (300, 200)
with Image.open(output_path) as img:
assert img.mode == "RGB"
def test_generate_share_preview_failure_returns_none(tmp_path):
bad = tmp_path / "bad.jpg"
bad.write_bytes(b"not an image")
assert ip.generate_share_preview(bad, tmp_path / "out.jpg") is None
+29
View File
@@ -36,6 +36,35 @@ def test_build_end_to_end_with_demo_content(tmp_path, monkeypatch, demo_root):
assert all(p.suffix in (".webp", ".jpg") for p in processed) assert all(p.suffix in (".webp", ".jpg") for p in processed)
def test_build_generates_photo_share_pages(tmp_path, monkeypatch, demo_root):
import hashlib
import re
output_dir = tmp_path / "output"
monkeypatch.setattr(site_builder, "BUILD_DIR", output_dir)
monkeypatch.setattr(site_builder, "GALLERY_FILE", demo_root / "gallery.yaml")
monkeypatch.setattr(site_builder, "SITE_FILE", demo_root / "site.yaml")
monkeypatch.setattr(site_builder, "IMG_DIR", demo_root / "photos")
monkeypatch.setattr(site_builder, "THEMES_DIR", demo_root / "themes")
site_builder.build()
gallery = yaml.safe_load((demo_root / "gallery.yaml").read_text(encoding="utf-8"))
sources = [img["src"] for img in gallery["gallery"]["images"]]
index_html = (output_dir / "index.html").read_text(encoding="utf-8")
ids = re.findall(r'data-photo-id="([^"]+)"', index_html)
assert len(ids) == len(sources)
first_hash = hashlib.sha256((demo_root / "photos" / sources[0]).read_bytes()).hexdigest()
assert ids[0] == first_hash
page = (output_dir / "photo" / first_hash / "index.html").read_text(encoding="utf-8")
assert f'content="https://lumeex.djeex.fr/img/share/{first_hash}.jpg"' in page
assert 'content="noindex, follow"' in page
assert (output_dir / "img" / "share" / f"{first_hash}.jpg").exists()
assert len(list((output_dir / "photo").iterdir())) == len(set(ids))
assert "/photo/" not in (output_dir / "sitemap.xml").read_text(encoding="utf-8")
def test_build_without_image_conversion_copies_originals(tmp_path, monkeypatch, demo_root): def test_build_without_image_conversion_copies_originals(tmp_path, monkeypatch, demo_root):
site_data = yaml.safe_load((demo_root / "site.yaml").read_text(encoding="utf-8")) site_data = yaml.safe_load((demo_root / "site.yaml").read_text(encoding="utf-8"))
site_data["build"]["convert_images"] = False site_data["build"]["convert_images"] = False