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
CI / build-and-scan (pull_request) Successful in 2m9s
This commit is contained in:
@@ -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>"><script>alert(1)</script></title>" in page
|
||||
|
||||
|
||||
def test_generate_gallery_json_from_images(tmp_path):
|
||||
images = [{"src": "hero/a.jpg"}, {"src": "hero/b.jpg"}]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user