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
+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
!.sh
!.gitignore
!.gitattributes
!.gitea
!.gitea/**
output/
+187
View File
@@ -1,6 +1,10 @@
// js for 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'
const setupIntersectionObserver = () => {
document.querySelectorAll('.appear').forEach(parent => {
@@ -163,6 +167,187 @@ const disableRightClickAndDrag = () => {
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
const setupScrollToTopButton = () => {
const scrollBtn = document.getElementById("scrollToTop");
@@ -190,9 +375,11 @@ document.addEventListener("DOMContentLoaded", () => {
setupIntersectionObserver();
setupLoader();
shuffleGallery();
openLinkedPhoto();
randomizeHeroBackground();
setupTagFilter();
disableRightClickAndDrag();
setupPhotoMenu();
setupScrollToTopButton();
fixNavSeparators();
});
+120
View File
@@ -331,6 +331,126 @@ h2 {
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 {
padding:10px;
margin:10px;
+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)
+49
View File
@@ -39,6 +39,55 @@ def test_render_gallery_images_with_and_without_tags():
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):
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"
ip.generate_favicon_ico({}, tmp_path, output_path)
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)
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):
site_data = yaml.safe_load((demo_root / "site.yaml").read_text(encoding="utf-8"))
site_data["build"]["convert_images"] = False