10 Commits
v1.2 ... v1.3.1

Author SHA1 Message Date
41450837f2 Versionning 2025-08-13 22:14:16 +00:00
4edeb8709a Click on photo tag -> Photo on top + scroll to top 2025-08-13 22:10:03 +00:00
6fc573c510 Merge pull request 'Slimmer docker image + fifo file check' (#7) from docker into main
Reviewed-on: #7
2025-08-13 18:21:24 +02:00
43c007c1fe Slimmer docker image + fifo file check 2025-08-13 16:20:46 +00:00
dfbd532efd Merge pull request 'Docker support' (#6) from docker into main
Reviewed-on: #6
2025-08-13 17:47:25 +02:00
efe1bbca29 new ensure_dir logic
+ better logs
+ docker files
+ new docs
2025-08-13 15:45:33 +00:00
7e1a5e659f Merge pull request 'errors' (#5) from errors into main
Reviewed-on: #5
2025-08-12 19:35:54 +02:00
f5a5aefd09 gallery.py starter + rename builder.py 2025-08-12 17:31:46 +00:00
f76420b2c3 favicon log + better robot.txt + modular starter 2025-08-12 17:08:31 +00:00
3901bf8acf Fixed typo 2025-08-11 17:15:46 +00:00
15 changed files with 599 additions and 394 deletions

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
.* .*
!.sh
!.gitignore !.gitignore
output/ output/
__pycache__/ __pycache__/

19
Dockerfile Normal file
View File

@ -0,0 +1,19 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY ./src/ ./src/
COPY ./build.py ./build.py
COPY ./gallery.py ./gallery.py
COPY ./config /app/default
COPY ./docker/.sh/entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
RUN printf '#!/bin/sh\n/app/entrypoint.sh build\n' > /usr/local/bin/build && chmod +x /usr/local/bin/build && \
printf '#!/bin/sh\n/app/entrypoint.sh gallery\n' > /usr/local/bin/gallery && chmod +x /usr/local/bin/gallery
ENTRYPOINT ["/app/entrypoint.sh"]

View File

@ -24,8 +24,7 @@ The project includes two thoughtfully designed themes—one modern, one minimali
## 📌 Table of Contents ## 📌 Table of Contents
- [✨ Features](#-features) - [✨ Features](#-features)
- [🐍 Python Installation](#-python-installation) - [🐳 Docker or 🐍 Python Installation](#-docker-or--python-installation)
- [⚙️ Configuration](#-configuration)
## ✨ Features ## ✨ Features
@ -57,28 +56,6 @@ The project includes two thoughtfully designed themes—one modern, one minimali
- *(Optional)* Converts images to WebP format for optimized performance - *(Optional)* Converts images to WebP format for optimized performance
- Outputs a complete static website ready to deploy on any web server - Outputs a complete static website ready to deploy on any web server
## 🐳 Docker or 🐍 Python Installation
## 🐍 Python Installation For comprehensive documentation on installation, configuration options, customization, and demos, please visit:
Run the Python scripts directly with the following prerequisites:
### Prerequisites
- Git
- Python 3.11 or above
### Installation Steps
```sh
git clone https://git.djeex.fr/Djeex/lumeex.git
cd lumeex
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
You are now ready to use Lumeex!
### ⚙️ Configuration
For comprehensive documentation on configuration options, customization, and demos, please visit:
https://lumeex.djeex.fr https://lumeex.djeex.fr

185
build.py
View File

@ -1,187 +1,6 @@
import logging import logging
from datetime import datetime from src.py.site_builder import build
from pathlib import Path
from shutil import copyfile
from PIL import Image
from src.py.utils import ensure_dir, copy_assets, load_yaml, load_theme_config
from src.py.css_generator import generate_css_variables, generate_fonts_css, generate_google_fonts_link
from src.py.image_processor import process_images, copy_original_images, convert_and_resize_image, generate_favicons_from_logo, generate_favicon_ico
from src.py.html_generator import render_template, render_gallery_images, generate_gallery_json_from_images, generate_robots_txt, generate_sitemap_xml
# Configure logging to display only the messages
logging.basicConfig(level=logging.INFO, format='%(message)s')
# Define key directories used throughout the script
SRC_DIR = Path.cwd()
BUILD_DIR = SRC_DIR / "output"
TEMPLATE_DIR = SRC_DIR / "src/templates"
IMG_DIR = SRC_DIR / "config/photos"
JS_DIR = SRC_DIR / "src/public/js"
STYLE_DIR = SRC_DIR / "src/public/style"
GALLERY_FILE = SRC_DIR / "config/gallery.yaml"
SITE_FILE = SRC_DIR / "config/site.yaml"
THEMES_DIR = SRC_DIR / "config/themes"
def build():
logging.info("🚀 Starting build...")
ensure_dir(BUILD_DIR)
copy_assets(JS_DIR, STYLE_DIR, BUILD_DIR)
# Defining build vars
build_date = datetime.now().strftime("%Y%m%d%H%M%S")
build_date_version = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
site_vars = load_yaml(SITE_FILE)
gallery_vars = load_yaml(GALLERY_FILE)
build_section = site_vars.get("build", {})
theme_name = site_vars.get("build", {}).get("theme", "default")
theme_vars, theme_dir = load_theme_config(theme_name, THEMES_DIR)
fonts_dir = theme_dir / "fonts"
theme_css_path = theme_dir / "theme.css"
canonical_url = site_vars.get("info", {}).get("canonical", "").rstrip("/")
canonical_home = f"{canonical_url}/"
canonical_legals = f"{canonical_url}/legals/"
# Copying theme.css if existing
if theme_css_path.exists():
dest_theme_css = BUILD_DIR / "style" / "theme.css"
dest_theme_css.parent.mkdir(parents=True, exist_ok=True)
copyfile(theme_css_path, dest_theme_css)
theme_css = f'<link rel="stylesheet" href="/style/theme.css?build_date={build_date}">'
logging.info(f"[✓] Theme CSS found, copied to build folder: {dest_theme_css}")
else:
theme_css = ""
logging.warning(f"[~] No theme.css found in {theme_css_path}, skipping theme CSS injection.")
preload_links = generate_fonts_css(fonts_dir, BUILD_DIR / "style" / "fonts.css", fonts_cfg=theme_vars.get("fonts"))
generate_css_variables(theme_vars.get("colors", {}), BUILD_DIR / "style" / "colors.css")
generate_favicons_from_logo(theme_vars, theme_dir, BUILD_DIR / "img" / "favicon")
generate_favicon_ico(theme_vars, theme_dir, BUILD_DIR / "favicon.ico")
# Converting and resizing images if enabled
convert_images = build_section.get("convert_images", True)
resize_images = build_section.get("resize_images", True)
logging.info(f"[~] convert_images = {convert_images}")
logging.info(f"[~] resize_images = {resize_images}")
hero_images = gallery_vars.get("hero", {}).get("images", [])
gallery_images = gallery_vars.get("gallery", {}).get("images", [])
if convert_images:
process_images(hero_images, resize_images, IMG_DIR, BUILD_DIR)
process_images(gallery_images, resize_images, IMG_DIR, BUILD_DIR)
else:
copy_original_images(hero_images, IMG_DIR, BUILD_DIR)
copy_original_images(gallery_images, IMG_DIR, BUILD_DIR)
if "hero" not in site_vars:
site_vars["hero"] = {} # Initialize an empty hero section
# Adding menu
menu_html = "\n".join(
f'<li class="nav-item appear"><a href="{item["href"]}">{item["label"]}</a></li>'
for item in site_vars.get("menu", {}).get("items", [])
)
site_vars["hero"]["menu_items"] = menu_html
if "footer" in site_vars:
site_vars["footer"]["menu_items"] = menu_html
# Adding Google fonts if existing
google_fonts_link = generate_google_fonts_link(theme_vars.get("google_fonts", []))
logging.info(f"[✓] Google Fonts link generated:\n{google_fonts_link}")
# Generating thumbnail
thumbnail_path = site_vars.get("social", {}).get("thumbnail")
if thumbnail_path:
src_thumb = IMG_DIR / thumbnail_path
dest_thumb_dir = BUILD_DIR / "img" / "social"
dest_thumb_dir.mkdir(parents=True, exist_ok=True)
dest_thumb = dest_thumb_dir / Path(thumbnail_path).name
try:
img = Image.open(src_thumb)
img = img.convert("RGB")
img = img.resize((1200, 630), Image.LANCZOS)
img.save(dest_thumb, "JPEG", quality=90)
logging.info(f"[✓] Thumbnail resized and saved to {dest_thumb}")
except Exception as e:
logging.error(f"[✗] Failed to process thumbnail: {e}")
else:
logging.warning("[~] No thumbnail found in social section")
# Defining head variables
head_vars = dict(site_vars.get("info", {}))
head_vars.update(theme_vars.get("colors", {}))
head_vars.update(site_vars.get("social", {}))
head_vars["thumbnail"] = f"/img/social/{Path(thumbnail_path).name}" if thumbnail_path else ""
head_vars["google_fonts_link"] = google_fonts_link
head_vars["font_preloads"] = "\n".join(preload_links)
head_vars["theme_css"] = theme_css
head_vars["build_date"] = build_date
head_vars["canonical"] = canonical_home
# Render the home page
head = render_template(TEMPLATE_DIR / "head.html", head_vars)
hero = render_template(TEMPLATE_DIR / "hero.html", {**site_vars["hero"], **head_vars})
footer = render_template(TEMPLATE_DIR / "footer.html", {**site_vars.get("footer", {}), **head_vars})
gallery_html = render_gallery_images(gallery_images)
gallery = render_template(TEMPLATE_DIR / "gallery.html", {"gallery_images": gallery_html})
signature = f"<!-- Build with Lumeex v1.2 | https://git.djeex.fr/Djeex/lumeex | {build_date_version} -->"
body = f"""
<body>
<div class="page-loader"><div class="spinner"></div></div>
{hero}
{gallery}
{footer}
</body>
"""
output_file = BUILD_DIR / "index.html"
with open(output_file, "w", encoding="utf-8") as f:
f.write(f"<!DOCTYPE html>\n{signature}\n<html lang='en'>\n{head}\n{body}\n</html>")
logging.info(f"[✓] HTML generated: {output_file}")
# Rendering legals page
head_vars["canonical"] = canonical_legals
legals_vars = site_vars.get("legals", {})
if legals_vars:
head = render_template(TEMPLATE_DIR / "head.html", head_vars)
ip_paragraphs = legals_vars.get("intellectual_property", [])
paragraphs_html = "\n".join(f"<p>{item['paragraph']}</p>" for item in ip_paragraphs)
legals_context = {
"hoster_name": legals_vars.get("hoster_name", ""),
"hoster_adress": legals_vars.get("hoster_adress", ""),
"hoster_contact": legals_vars.get("hoster_contact", ""),
"intellectual_property": paragraphs_html,
}
legals_body = render_template(TEMPLATE_DIR / "legals.html", legals_context)
legals_html = f"<!DOCTYPE html>\n{signature}\n<html lang='en'>\n{head}\n{legals_body}\n{footer}\n</html>"
output_legals = BUILD_DIR / "legals" / "index.html"
output_legals.parent.mkdir(parents=True, exist_ok=True)
with open(output_legals, "w", encoding="utf-8") as f:
f.write(legals_html)
logging.info(f"[✓] Legals page generated: {output_legals}")
else:
logging.warning("[~] No legals section found in site.yaml")
# Hero carrousel generator
if hero_images:
generate_gallery_json_from_images(hero_images, BUILD_DIR)
else:
logging.warning("[~] No hero images found, skipping JSON generation.")
# Sitemap and robot.txt generator
site_info = site_vars.get("info", {})
canonical_url = site_info.get("canonical", "").rstrip("/")
if canonical_url:
allowed_pages = ["/", "/legals/"]
generate_robots_txt(canonical_url, allowed_pages, BUILD_DIR)
generate_sitemap_xml(canonical_url, allowed_pages, BUILD_DIR)
else:
logging.warning("[~] No canonical URL found in site.yaml info section, skipping robots.txt and sitemap.xml generation.")
logging.info("✅ Build complete.")
if __name__ == "__main__": if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(message)s")
build() build()

View File

@ -1,6 +1,3 @@
# Use gallery.py to automatically add photos stored in your /config/photos/gallery folder
# Add tags to your photos as shown below
# remove the # before [] if you removed all images to use gallery.py again
hero: hero:
images: [] images: []
gallery: gallery:

78
docker/.sh/entrypoint.sh Normal file
View File

@ -0,0 +1,78 @@
#!/bin/bash
set -e
CYAN="\033[1;36m"
NC="\033[0m"
copy_default_config() {
echo "Checking configuration directory..."
if [ ! -d "/app/config" ]; then
mkdir -p /app/config
fi
echo "Checking if default config files need to be copied..."
files_copied=false
for file in /app/default/*; do
filename=$(basename "$file")
target="/app/config/$filename"
if [ ! -e "$target" ]; then
echo "Copying default config file: $filename"
cp -r "$file" "$target"
files_copied=true
fi
done
if [ "$files_copied" = true ]; then
echo "Default configuration files copied successfully."
else
echo "No default files needed to be copied."
fi
}
start_server() {
# Clean up old FIFOs
[ -p /tmp/build_logs_fifo ] && rm /tmp/build_logs_fifo
[ -p /tmp/build_logs_fifo2 ] && rm /tmp/build_logs_fifo2
mkfifo /tmp/build_logs_fifo
mkfifo /tmp/build_logs_fifo2
cat /tmp/build_logs_fifo >&2 &
cat /tmp/build_logs_fifo2 >&2 &
echo "Starting HTTP server on port 3000..."
python3 -u -m http.server 3000 -d /app/output &
SERVER_PID=$!
trap "echo 'Stopping server...'; kill -TERM $SERVER_PID 2>/dev/null; wait $SERVER_PID; exit 0" SIGINT SIGTERM
wait $SERVER_PID
}
if [ $# -eq 0 ]; then
echo -e "${CYAN}╭───────────────────────────────────────────╮${NC}"
echo -e "${CYAN}${NC} Lum${CYAN}eex${NC} - Version 1.3.1${NC} ${CYAN}${NC}"
echo -e "${CYAN}├───────────────────────────────────────────┤${NC}"
echo -e "${CYAN}${NC} Source: https://git.djeex.fr/Djeex/lumeex ${CYAN}${NC}"
echo -e "${CYAN}${NC} Mirror: https://github.com/Djeex/lumeex ${CYAN}${NC}"
echo -e "${CYAN}${NC} Documentation: https://lumeex.djeex.fr ${CYAN}${NC}"
echo -e "${CYAN}╰───────────────────────────────────────────╯${NC}"
copy_default_config
start_server
fi
case "$1" in
build)
echo "Running build.py..."
python3 -u /app/build.py 2>&1 | tee /tmp/build_logs_fifo
;;
gallery)
echo "Running gallery.py..."
python3 -u /app/gallery.py 2>&1 | tee /tmp/build_logs_fifo2
;;
*)
echo "Unknown command: $1"
exec "$@"
;;
esac

View File

@ -0,0 +1,10 @@
services:
lumeex:
container_name: lmx
build: ..
volumes:
- ../config:/app/config # mount config directory
- ../output:/app/output # mount output directory
ports:
- "3000:3000"

View File

@ -1,113 +1,7 @@
import yaml import logging
import os from src.py.gallery_builder import update_gallery, update_hero
from pathlib import Path
# YAML file paths
GALLERY_YAML = "config/gallery.yaml"
# Image directories
GALLERY_DIR = Path("config/photos/gallery")
HERO_DIR = Path("config/photos/hero")
def load_yaml(path):
print(f"[→] Loading {path}...")
if not os.path.exists(path):
print(f"[✗] File not found: {path}")
return {}
with open(path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
images = data.get("images", []) or []
print(f"[✓] Loaded {len(images)} image(s) from {path}")
return data
def save_yaml(data, path):
with open(path, "w", encoding="utf-8") as f:
yaml.dump(data, f, sort_keys=False, allow_unicode=True)
print(f"[✓] Saved updated YAML to {path}")
def get_all_image_paths(directory):
return sorted([
str(p.relative_to(directory.parent)).replace("\\", "/")
for p in directory.rglob("*")
if p.suffix.lower() in [".jpg", ".jpeg", ".png", ".webp"]
])
def update_gallery():
print("\n=== Updating gallery.yaml (gallery section) ===")
gallery = load_yaml(GALLERY_YAML)
# Access the 'gallery' section within the gallery data, or initialize it if it doesn't exist
gallery_section = gallery.get("gallery", {})
# Access the 'images' list within the 'gallery' section, or initialize it if it doesn't exist
gallery_images = gallery_section.get("images", [])
all_images = set(get_all_image_paths(GALLERY_DIR))
known_images = {img["src"] for img in gallery_images}
# Add new images
new_images = [
{"src": path, "tags": []}
for path in all_images
if path not in known_images
]
if new_images:
gallery_images.extend(new_images)
print(f"[✓] Added {len(new_images)} new image(s) to gallery.yaml (gallery)")
# Remove deleted images
deleted_images = known_images - all_images
if deleted_images:
gallery_images = [img for img in gallery_images if img["src"] not in deleted_images]
print(f"[✓] Removed {len(deleted_images)} deleted image(s) from gallery.yaml (gallery)")
# Update the 'gallery' section with the modified 'images' list
gallery_section["images"] = gallery_images
gallery["gallery"] = gallery_section
save_yaml(gallery, GALLERY_YAML)
if not new_images and not deleted_images:
print("[✓] No changes to gallery.yaml (gallery)")
def update_hero():
print("\n=== Updating gallery.yaml (hero section) ===")
gallery = load_yaml(GALLERY_YAML)
# Access the 'hero' section within the gallery data, or initialize it if it doesn't exist
hero_section = gallery.get("hero", {})
# Access the 'images' list within the 'hero' section, or initialize it if it doesn't exist
hero_images = hero_section.get("images", [])
all_images = set(get_all_image_paths(HERO_DIR))
known_images = {img["src"] for img in hero_images}
# Add new images
new_images = [
{"src": path}
for path in all_images
if path not in known_images
]
if new_images:
hero_images.extend(new_images)
print(f"[✓] Added {len(new_images)} new image(s) to gallery.yaml (hero)")
# Remove deleted images
deleted_images = known_images - all_images
if deleted_images:
hero_images = [img for img in hero_images if img["src"] not in deleted_images]
print(f"[✓] Removed {len(deleted_images)} deleted image(s) from gallery.yaml (hero)")
# Update the 'hero' section with the modified 'images' list
hero_section["images"] = hero_images
gallery["hero"] = hero_section
save_yaml(gallery, GALLERY_YAML)
if not new_images and not deleted_images:
print("[✓] No changes to gallery.yaml (hero)")
if __name__ == "__main__": if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(message)s")
update_gallery() update_gallery()
update_hero() update_hero()

View File

@ -17,24 +17,11 @@ const setupLoader = () => {
window.addEventListener('load', () => { window.addEventListener('load', () => {
setTimeout(() => { setTimeout(() => {
const loader = document.querySelector('.page-loader'); const loader = document.querySelector('.page-loader');
if (loader) { if (loader) loader.classList.add('hidden');
loader.classList.add('hidden');
}
}, 50); }, 50);
}); });
}; };
// Gallery randomizer to shuffle gallery sections on page load
const shuffleGallery = () => {
const gallery = document.querySelector('.gallery');
if (!gallery) return;
const sections = Array.from(gallery.querySelectorAll('.section'));
while (sections.length) {
const randomIndex = Math.floor(Math.random() * sections.length);
gallery.appendChild(sections.splice(randomIndex, 1)[0]);
}
};
// Hero background randomizer // Hero background randomizer
const randomizeHeroBackground = () => { const randomizeHeroBackground = () => {
const heroBg = document.querySelector(".hero-background"); const heroBg = document.querySelector(".hero-background");
@ -65,32 +52,74 @@ const randomizeHeroBackground = () => {
.catch(console.error); .catch(console.error);
}; };
// Gallery randomizer to shuffle gallery sections on page load
const shuffleGallery = () => {
const gallery = document.querySelector('.gallery');
if (!gallery) return;
const sections = Array.from(gallery.querySelectorAll('.section'));
while (sections.length) {
const randomIndex = Math.floor(Math.random() * sections.length);
gallery.appendChild(sections.splice(randomIndex, 1)[0]);
}
};
// Tags filter functionality // Tags filter functionality
const setupTagFilter = () => { const setupTagFilter = () => {
const galleryContainer = document.querySelector('#gallery');
const allSections = document.querySelectorAll('.section[data-tags]'); const allSections = document.querySelectorAll('.section[data-tags]');
const allTags = document.querySelectorAll('.tag'); const allTags = document.querySelectorAll('.tag');
let activeTags = []; let activeTags = [];
let lastClickedTag = null; // mémorise le dernier tag cliqué
const applyFilter = () => { const applyFilter = () => {
let filteredSections = [];
let matchingSection = null;
allSections.forEach((section) => { allSections.forEach((section) => {
const sectionTags = section.dataset.tags.toLowerCase().split(/\s+/); const sectionTags = section.dataset.tags.toLowerCase().split(/\s+/);
const hasAllTags = activeTags.every((tag) => sectionTags.includes(tag)); const hasAllTags = activeTags.every((tag) => sectionTags.includes(tag));
section.style.display = hasAllTags ? '' : 'none'; section.style.display = hasAllTags ? '' : 'none';
if (hasAllTags) {
filteredSections.push(section);
if (lastClickedTag && sectionTags.includes(lastClickedTag) && !matchingSection) {
matchingSection = section;
}
}
}); });
// Réorganise : la photo correspondante au dernier tag cliqué en premier
if (matchingSection && galleryContainer.contains(matchingSection)) {
galleryContainer.prepend(matchingSection);
}
// Met à jour le style des tags
allTags.forEach((tagEl) => { allTags.forEach((tagEl) => {
const tagText = tagEl.textContent.replace('#', '').toLowerCase(); const tagText = tagEl.textContent.replace('#', '').toLowerCase();
tagEl.classList.toggle('active', activeTags.includes(tagText)); tagEl.classList.toggle('active', activeTags.includes(tagText));
}); });
// Met à jour l'URL
const base = window.location.pathname; const base = window.location.pathname;
const query = activeTags.length > 0 ? `?tag=${activeTags.join(',')}` : ''; const query = activeTags.length > 0 ? `?tag=${activeTags.join(',')}` : '';
window.history.pushState({}, '', base + query); window.history.pushState({}, '', base + query);
// Scroll jusqu'à la galerie
if (galleryContainer) {
galleryContainer.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}; };
allTags.forEach((tagEl) => { allTags.forEach((tagEl) => {
tagEl.addEventListener('click', () => { tagEl.addEventListener('click', () => {
const tagText = tagEl.textContent.replace('#', '').toLowerCase(); const tagText = tagEl.textContent.replace('#', '').toLowerCase();
activeTags = activeTags.includes(tagText) lastClickedTag = tagText; // mémorise le dernier tag cliqué
? activeTags.filter((t) => t !== tagText)
: [...activeTags, tagText]; if (activeTags.includes(tagText)) {
activeTags = activeTags.filter((t) => t !== tagText);
} else {
activeTags.push(tagText);
}
applyFilter(); applyFilter();
}); });
}); });
@ -100,29 +129,24 @@ const setupTagFilter = () => {
const urlTags = params.get('tag'); const urlTags = params.get('tag');
if (urlTags) { if (urlTags) {
activeTags = urlTags.split(',').map((t) => t.toLowerCase()); activeTags = urlTags.split(',').map((t) => t.toLowerCase());
lastClickedTag = activeTags[activeTags.length - 1] || null;
applyFilter(); applyFilter();
} }
}); });
}; };
// Disable right-click context menu and image dragging // Disable right click and drag
const disableRightClickAndDrag = () => { const disableRightClickAndDrag = () => {
document.addEventListener("contextmenu", (e) => e.preventDefault()); document.addEventListener('contextmenu', (e) => e.preventDefault());
document.addEventListener("dragstart", (e) => { document.addEventListener('dragstart', (e) => e.preventDefault());
if (e.target.tagName === "IMG") {
e.preventDefault();
}
});
}; };
// Scroll-to-top button functionality // Scroll to top button
const setupScrollToTopButton = () => { const setupScrollToTopButton = () => {
const scrollBtn = document.getElementById("scrollToTop"); const scrollToTopButton = document.querySelector('.scroll-to-top');
window.addEventListener("scroll", () => { if (!scrollToTopButton) return;
scrollBtn.style.display = window.scrollY > 300 ? "block" : "none"; scrollToTopButton.addEventListener('click', () => {
}); window.scrollTo({ top: 0, behavior: 'smooth' });
scrollBtn.addEventListener("click", () => {
window.scrollTo({ top: 0, behavior: "smooth" });
}); });
}; };

View File

@ -333,8 +333,12 @@ h2 {
font-size: 22px; font-size: 22px;
} }
/* Sections */
.gallery {
padding-top: 15px;
}
/* Sections */
.section { .section {
max-width: 1140px; max-width: 1140px;
margin:auto; margin:auto;
@ -508,4 +512,9 @@ h2 {
padding-left: 0; padding-left: 0;
margin-top: 60px; margin-top: 60px;
} }
.gallery {
margin: 10% 5% 0 5%;
padding-top: 15px;
}
} }

109
src/py/gallery_builder.py Normal file
View File

@ -0,0 +1,109 @@
import yaml
import os
from pathlib import Path
# YAML file paths
GALLERY_YAML = "config/gallery.yaml"
# Image directories
GALLERY_DIR = Path("config/photos/gallery")
HERO_DIR = Path("config/photos/hero")
def load_yaml(path):
print(f"[→] Loading {path}...")
if not os.path.exists(path):
print(f"[✗] File not found: {path}")
return {}
with open(path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
images = data.get("images", []) or []
print(f"[✓] Loaded {len(images)} image(s) from {path}")
return data
def save_yaml(data, path):
with open(path, "w", encoding="utf-8") as f:
yaml.dump(data, f, sort_keys=False, allow_unicode=True)
print(f"[✓] Saved updated YAML to {path}")
def get_all_image_paths(directory):
return sorted([
str(p.relative_to(directory.parent)).replace("\\", "/")
for p in directory.rglob("*")
if p.suffix.lower() in [".jpg", ".jpeg", ".png", ".webp"]
])
def update_gallery():
print("\n=== Updating gallery.yaml (gallery section) ===")
gallery = load_yaml(GALLERY_YAML)
# Access the 'gallery' section within the gallery data, or initialize it if it doesn't exist
gallery_section = gallery.get("gallery", {})
# Access the 'images' list within the 'gallery' section, or initialize it if it doesn't exist
gallery_images = gallery_section.get("images", [])
all_images = set(get_all_image_paths(GALLERY_DIR))
known_images = {img["src"] for img in gallery_images}
# Add new images
new_images = [
{"src": path, "tags": []}
for path in all_images
if path not in known_images
]
if new_images:
gallery_images.extend(new_images)
print(f"[✓] Added {len(new_images)} new image(s) to gallery.yaml (gallery)")
# Remove deleted images
deleted_images = known_images - all_images
if deleted_images:
gallery_images = [img for img in gallery_images if img["src"] not in deleted_images]
print(f"[✓] Removed {len(deleted_images)} deleted image(s) from gallery.yaml (gallery)")
# Update the 'gallery' section with the modified 'images' list
gallery_section["images"] = gallery_images
gallery["gallery"] = gallery_section
save_yaml(gallery, GALLERY_YAML)
if not new_images and not deleted_images:
print("[✓] No changes to gallery.yaml (gallery)")
def update_hero():
print("\n=== Updating gallery.yaml (hero section) ===")
gallery = load_yaml(GALLERY_YAML)
# Access the 'hero' section within the gallery data, or initialize it if it doesn't exist
hero_section = gallery.get("hero", {})
# Access the 'images' list within the 'hero' section, or initialize it if it doesn't exist
hero_images = hero_section.get("images", [])
all_images = set(get_all_image_paths(HERO_DIR))
known_images = {img["src"] for img in hero_images}
# Add new images
new_images = [
{"src": path}
for path in all_images
if path not in known_images
]
if new_images:
hero_images.extend(new_images)
print(f"[✓] Added {len(new_images)} new image(s) to gallery.yaml (hero)")
# Remove deleted images
deleted_images = known_images - all_images
if deleted_images:
hero_images = [img for img in hero_images if img["src"] not in deleted_images]
print(f"[✓] Removed {len(deleted_images)} deleted image(s) from gallery.yaml (hero)")
# Update the 'hero' section with the modified 'images' list
hero_section["images"] = hero_images
gallery["hero"] = hero_section
save_yaml(gallery, GALLERY_YAML)
if not new_images and not deleted_images:
print("[✓] No changes to gallery.yaml (hero)")

View File

@ -36,16 +36,30 @@ def generate_gallery_json_from_images(images, output_dir):
def generate_robots_txt(canonical_url, allowed_paths, output_dir): def generate_robots_txt(canonical_url, allowed_paths, output_dir):
robots_lines = ["User-agent: *"] robots_lines = ["User-agent: *"]
for path in allowed_paths:
robots_lines.append(f"Allow: {path}") # Block everything by default
robots_lines.append("Disallow: /") robots_lines.append("Disallow: /")
# Explicitly allow certain paths
for path in allowed_paths:
if not path.startswith("/"):
path = "/" + path
robots_lines.append(f"Allow: {path}")
robots_lines.append("") robots_lines.append("")
robots_lines.append(f"Sitemap: {canonical_url}/sitemap.xml") robots_lines.append(f"Sitemap: {canonical_url.rstrip('/')}/sitemap.xml")
content = "\n".join(robots_lines) content = "\n".join(robots_lines)
output_path = output_dir / "robots.txt" output_path = Path(output_dir) / "robots.txt"
with open(output_path, "w", encoding="utf-8") as f:
f.write(content) try:
logging.info(f"[✓] robots.txt generated at {output_path}") output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
f.write(content)
logging.info(f"[✓] robots.txt generated at {output_path}")
except Exception as e:
logging.error(f"[✗] Failed to write robots.txt: {e}")
def generate_sitemap_xml(canonical_url, allowed_paths, output_dir): def generate_sitemap_xml(canonical_url, allowed_paths, output_dir):
urlset_start = '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' urlset_start = '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'

View File

@ -1,73 +1,123 @@
import logging import logging
from pathlib import Path from pathlib import Path
from PIL import Image from PIL import Image, features
from shutil import copyfile from shutil import copyfile
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."""
try: try:
if not input_path.exists():
logging.error(f"[✗] Image file not found: {input_path}")
return
img = Image.open(input_path) img = Image.open(input_path)
if img.mode != "RGB": if img.mode != "RGB":
img = img.convert("RGB") img = img.convert("RGB")
if resize: if resize:
width, height = img.size width, height = img.size
if width > max_width: if width > max_width:
new_height = int((max_width / width) * height) new_height = int((max_width / width) * height)
img = img.resize((max_width, new_height), Image.LANCZOS) img = img.resize((max_width, new_height), Image.LANCZOS)
output_path.parent.mkdir(parents=True, exist_ok=True) output_path.parent.mkdir(parents=True, exist_ok=True)
img.save(output_path, "WEBP", quality=100)
logging.info(f"[✓] Processed: {input_path}{output_path}") # Check WebP support, otherwise fallback to JPEG
fmt = "WEBP" if features.check("webp") else "JPEG"
if fmt == "JPEG":
output_path = output_path.with_suffix(".jpg")
img.save(output_path, fmt, quality=90 if fmt == "JPEG" else 100)
logging.info(f"[✓] Processed image: {input_path}{output_path}")
except Exception as e: except Exception as e:
logging.error(f"[✗] Failed to process {input_path}: {e}") logging.error(f"[✗] Error processing image {input_path}: {e}")
def process_images(images, resize_images, img_dir, build_dir): def process_images(images, resize_images, img_dir, build_dir):
"""Process a list of image references and update paths to optimized versions."""
for img in images: for img in images:
src_path = img_dir / img["src"] src_path = img_dir / img["src"]
webp_path = build_dir / "img" / Path(img["src"]).with_suffix(".webp") webp_path = build_dir / "img" / Path(img["src"]).with_suffix(".webp")
convert_and_resize_image(src_path, webp_path, resize=resize_images) convert_and_resize_image(src_path, webp_path, resize=resize_images)
img["src"] = str(Path(img["src"]).with_suffix(".webp"))
if webp_path.exists():
img["src"] = str(Path(img["src"]).with_suffix(".webp"))
else:
# Fallback if WebP not created
jpg_path = webp_path.with_suffix(".jpg")
if jpg_path.exists():
img["src"] = str(Path(img["src"]).with_suffix(".jpg"))
def copy_original_images(images, img_dir, build_dir): def copy_original_images(images, img_dir, build_dir):
"""Copy original image files without processing."""
for img in images: for img in images:
src_path = img_dir / img["src"] src_path = img_dir / img["src"]
dest_path = build_dir / "img" / img["src"] dest_path = build_dir / "img" / img["src"]
try: try:
if not src_path.exists():
logging.error(f"[✗] Original image not found: {src_path}")
continue
dest_path.parent.mkdir(parents=True, exist_ok=True) dest_path.parent.mkdir(parents=True, exist_ok=True)
copyfile(src_path, dest_path) copyfile(src_path, dest_path)
logging.info(f"[✓] Copied original: {src_path}{dest_path}") logging.info(f"[✓] Copied original: {src_path}{dest_path}")
except Exception as e: except Exception as e:
logging.error(f"[✗] Failed to copy {src_path}: {e}") logging.error(f"[✗] Error copying {src_path}: {e}")
def get_favicon_path(theme_vars, theme_dir):
"""Retrieve the favicon path from theme variables, ensuring it exists."""
fav_path = theme_vars.get("favicon", {}).get("path")
if not fav_path:
logging.warning("[~] No favicon path defined in theme.yaml")
return None
path = Path(fav_path)
if not path.is_absolute():
path = theme_dir / path
if not path.exists():
logging.error(f"[✗] Favicon not found: {path}")
return None
return path
def generate_favicons_from_logo(theme_vars, theme_dir, output_dir): def generate_favicons_from_logo(theme_vars, theme_dir, output_dir):
"""Generate multiple PNG favicons from a single source image."""
logo_path = get_favicon_path(theme_vars, theme_dir) logo_path = get_favicon_path(theme_vars, theme_dir)
if not logo_path: if not logo_path:
logging.warning("[~] No favicon path defined, skipping favicon PNGs.") logging.warning("[~] PNG favicons not generated.")
return return
output_dir.mkdir(parents=True, exist_ok=True)
specs = [(32, "favicon-32.png"), (96, "favicon-96.png"), (128, "favicon-128.png"), try:
(192, "favicon-192.png"), (196, "favicon-196.png"), (152, "favicon-152.png"), (180, "favicon-180.png")] output_dir.mkdir(parents=True, exist_ok=True)
img = Image.open(logo_path).convert("RGBA") specs = [
for size, name in specs: (32, "favicon-32.png"), (96, "favicon-96.png"), (128, "favicon-128.png"),
img.resize((size, size), Image.LANCZOS).save(output_dir / name, format="PNG") (192, "favicon-192.png"), (196, "favicon-196.png"),
logging.info(f"[✓] Favicons generated in {output_dir}") (152, "favicon-152.png"), (180, "favicon-180.png")
]
img = Image.open(logo_path).convert("RGBA")
for size, name in specs:
img.resize((size, size), Image.LANCZOS).save(output_dir / name, format="PNG")
logging.info(f"[✓] PNG favicons generated in {output_dir}")
except Exception as e:
logging.error(f"[✗] Error generating PNG favicons: {e}")
def generate_favicon_ico(theme_vars, theme_dir, output_path): def generate_favicon_ico(theme_vars, theme_dir, output_path):
"""Generate a multi-size favicon.ico from a source image."""
logo_path = get_favicon_path(theme_vars, theme_dir) logo_path = get_favicon_path(theme_vars, theme_dir)
if not logo_path: if not logo_path:
logging.warning("[~] No favicon path defined, skipping .ico generation.") logging.warning("[~] favicon.ico not generated.")
return return
try: try:
img = Image.open(logo_path).convert("RGBA") img = Image.open(logo_path).convert("RGBA")
output_path.parent.mkdir(parents=True, exist_ok=True) output_path.parent.mkdir(parents=True, exist_ok=True)
img.save(output_path, format="ICO", sizes=[(16, 16), (32, 32), (48, 48)]) img.save(output_path, format="ICO", sizes=[(16, 16), (32, 32), (48, 48)])
logging.info(f"[✓] favicon.ico generated at {output_path}") logging.info(f"[✓] favicon.ico generated in {output_path}")
except Exception as e:
logging.error(f"[✗] Failed to generate favicon.ico: {e}")
def get_favicon_path(theme_vars, theme_dir): except Exception as e:
fav_path = theme_vars.get("favicon", {}).get("path") logging.error(f"[✗] Error generating favicon.ico: {e}")
if not fav_path:
return None
path = Path(fav_path)
if not path.is_absolute():
path = theme_dir / path
return path if path.exists() else None

189
src/py/site_builder.py Normal file
View File

@ -0,0 +1,189 @@
import logging
from datetime import datetime
from pathlib import Path
from shutil import copyfile
from PIL import Image
from .utils import ensure_dir, copy_assets, load_yaml, load_theme_config
from .css_generator import generate_css_variables, generate_fonts_css, generate_google_fonts_link
from .image_processor import process_images, copy_original_images, convert_and_resize_image, generate_favicons_from_logo, generate_favicon_ico
from .html_generator import render_template, render_gallery_images, generate_gallery_json_from_images, generate_robots_txt, generate_sitemap_xml
# Configure logging to display only the messages
logging.basicConfig(level=logging.INFO, format='%(message)s')
# Define key directories used throughout the script
SRC_DIR = Path.cwd()
BUILD_DIR = SRC_DIR / "output"
TEMPLATE_DIR = SRC_DIR / "src/templates"
IMG_DIR = SRC_DIR / "config/photos"
JS_DIR = SRC_DIR / "src/public/js"
STYLE_DIR = SRC_DIR / "src/public/style"
GALLERY_FILE = SRC_DIR / "config/gallery.yaml"
SITE_FILE = SRC_DIR / "config/site.yaml"
THEMES_DIR = SRC_DIR / "config/themes"
def build():
build_version = "v1.3.1"
logging.info("\n")
logging.info("=" * 24)
logging.info(f"🚀 Lumeex builder {build_version}")
logging.info("=" * 24)
logging.info("\n === Starting build === ")
ensure_dir(BUILD_DIR)
copy_assets(JS_DIR, STYLE_DIR, BUILD_DIR)
# Defining build vars
build_date = datetime.now().strftime("%Y%m%d%H%M%S")
build_date_version = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
site_vars = load_yaml(SITE_FILE)
gallery_vars = load_yaml(GALLERY_FILE)
build_section = site_vars.get("build", {})
theme_name = site_vars.get("build", {}).get("theme", "default")
theme_vars, theme_dir = load_theme_config(theme_name, THEMES_DIR)
fonts_dir = theme_dir / "fonts"
theme_css_path = theme_dir / "theme.css"
canonical_url = site_vars.get("info", {}).get("canonical", "").rstrip("/")
canonical_home = f"{canonical_url}/"
canonical_legals = f"{canonical_url}/legals/"
# Copying theme.css if existing
if theme_css_path.exists():
dest_theme_css = BUILD_DIR / "style" / "theme.css"
dest_theme_css.parent.mkdir(parents=True, exist_ok=True)
copyfile(theme_css_path, dest_theme_css)
theme_css = f'<link rel="stylesheet" href="/style/theme.css?build_date={build_date}">'
logging.info(f"[✓] Theme CSS found, copied to build folder: {dest_theme_css}")
else:
theme_css = ""
logging.warning(f"[~] No theme.css found in {theme_css_path}, skipping theme CSS injection.")
preload_links = generate_fonts_css(fonts_dir, BUILD_DIR / "style" / "fonts.css", fonts_cfg=theme_vars.get("fonts"))
generate_css_variables(theme_vars.get("colors", {}), BUILD_DIR / "style" / "colors.css")
generate_favicons_from_logo(theme_vars, theme_dir, BUILD_DIR / "img" / "favicon")
generate_favicon_ico(theme_vars, theme_dir, BUILD_DIR / "favicon.ico")
# Converting and resizing images if enabled
convert_images = build_section.get("convert_images", True)
resize_images = build_section.get("resize_images", True)
logging.info(f"[~] convert_images = {convert_images}")
logging.info(f"[~] resize_images = {resize_images}")
hero_images = gallery_vars.get("hero", {}).get("images", [])
gallery_images = gallery_vars.get("gallery", {}).get("images", [])
if convert_images:
process_images(hero_images, resize_images, IMG_DIR, BUILD_DIR)
process_images(gallery_images, resize_images, IMG_DIR, BUILD_DIR)
else:
copy_original_images(hero_images, IMG_DIR, BUILD_DIR)
copy_original_images(gallery_images, IMG_DIR, BUILD_DIR)
if "hero" not in site_vars:
site_vars["hero"] = {} # Initialize an empty hero section
# Adding menu
menu_html = "\n".join(
f'<li class="nav-item appear"><a href="{item["href"]}">{item["label"]}</a></li>'
for item in site_vars.get("menu", {}).get("items", [])
)
site_vars["hero"]["menu_items"] = menu_html
if "footer" in site_vars:
site_vars["footer"]["menu_items"] = menu_html
# Adding Google fonts if existing
google_fonts_link = generate_google_fonts_link(theme_vars.get("google_fonts", []))
logging.info(f"[✓] Google Fonts link generated")
# Generating thumbnail
thumbnail_path = site_vars.get("social", {}).get("thumbnail")
if thumbnail_path:
src_thumb = IMG_DIR / thumbnail_path
dest_thumb_dir = BUILD_DIR / "img" / "social"
dest_thumb_dir.mkdir(parents=True, exist_ok=True)
dest_thumb = dest_thumb_dir / Path(thumbnail_path).name
try:
img = Image.open(src_thumb)
img = img.convert("RGB")
img = img.resize((1200, 630), Image.LANCZOS)
img.save(dest_thumb, "JPEG", quality=90)
logging.info(f"[✓] Thumbnail resized and saved to {dest_thumb}")
except Exception as e:
logging.error(f"[✗] Failed to process thumbnail: {e}")
else:
logging.warning("[~] No thumbnail found in social section")
# Defining head variables
head_vars = dict(site_vars.get("info", {}))
head_vars.update(theme_vars.get("colors", {}))
head_vars.update(site_vars.get("social", {}))
head_vars["thumbnail"] = f"/img/social/{Path(thumbnail_path).name}" if thumbnail_path else ""
head_vars["google_fonts_link"] = google_fonts_link
head_vars["font_preloads"] = "\n".join(preload_links)
head_vars["theme_css"] = theme_css
head_vars["build_date"] = build_date
head_vars["canonical"] = canonical_home
# Render the home page
head = render_template(TEMPLATE_DIR / "head.html", head_vars)
hero = render_template(TEMPLATE_DIR / "hero.html", {**site_vars["hero"], **head_vars})
footer = render_template(TEMPLATE_DIR / "footer.html", {**site_vars.get("footer", {}), **head_vars})
gallery_html = render_gallery_images(gallery_images)
gallery = render_template(TEMPLATE_DIR / "gallery.html", {"gallery_images": gallery_html})
signature = f"<!-- Build with Lumeex {build_version} | https://git.djeex.fr/Djeex/lumeex | {build_date_version} -->"
body = f"""
<body>
<div class="page-loader"><div class="spinner"></div></div>
{hero}
{gallery}
{footer}
</body>
"""
output_file = BUILD_DIR / "index.html"
with open(output_file, "w", encoding="utf-8") as f:
f.write(f"<!DOCTYPE html>\n{signature}\n<html lang='en'>\n{head}\n{body}\n</html>")
logging.info(f"[✓] HTML generated: {output_file}")
# Rendering legals page
head_vars["canonical"] = canonical_legals
legals_vars = site_vars.get("legals", {})
if legals_vars:
head = render_template(TEMPLATE_DIR / "head.html", head_vars)
ip_paragraphs = legals_vars.get("intellectual_property", [])
paragraphs_html = "\n".join(f"<p>{item['paragraph']}</p>" for item in ip_paragraphs)
legals_context = {
"hoster_name": legals_vars.get("hoster_name", ""),
"hoster_adress": legals_vars.get("hoster_adress", ""),
"hoster_contact": legals_vars.get("hoster_contact", ""),
"intellectual_property": paragraphs_html,
}
legals_body = render_template(TEMPLATE_DIR / "legals.html", legals_context)
legals_html = f"<!DOCTYPE html>\n{signature}\n<html lang='en'>\n{head}\n{legals_body}\n{footer}\n</html>"
output_legals = BUILD_DIR / "legals" / "index.html"
output_legals.parent.mkdir(parents=True, exist_ok=True)
with open(output_legals, "w", encoding="utf-8") as f:
f.write(legals_html)
logging.info(f"[✓] Legals page generated: {output_legals}")
else:
logging.warning("[~] No legals section found in site.yaml")
# Hero carrousel generator
if hero_images:
generate_gallery_json_from_images(hero_images, BUILD_DIR)
else:
logging.warning("[~] No hero images found, skipping JSON generation.")
# Sitemap and robot.txt generator
site_info = site_vars.get("info", {})
canonical_url = site_info.get("canonical", "").rstrip("/")
if canonical_url:
allowed_pages = ["/", "/legals/"]
generate_robots_txt(canonical_url, allowed_pages, BUILD_DIR)
generate_sitemap_xml(canonical_url, allowed_pages, BUILD_DIR)
else:
logging.warning("[~] No canonical URL found in site.yaml info section, skipping robots.txt and sitemap.xml generation.")
logging.info("✅ Build complete.")

View File

@ -19,10 +19,25 @@ def load_theme_config(theme_name, themes_dir):
theme_vars = yaml.safe_load(f) theme_vars = yaml.safe_load(f)
return theme_vars, theme_dir return theme_vars, theme_dir
def ensure_dir(path): def clear_dir(path: Path):
if path.exists(): if not path.exists():
rmtree(path) path.mkdir(parents=True)
path.mkdir(parents=True) return
# Remove all files and subdirectories inside path, but not path itself
for child in path.iterdir():
if child.is_file() or child.is_symlink():
child.unlink() # delete file or symlink
elif child.is_dir():
rmtree(child) # delete directory and contents
# Then replace your ensure_dir with this:
def ensure_dir(path: Path):
if not path.exists():
path.mkdir(parents=True)
else:
clear_dir(path)
def copy_assets(js_dir, style_dir, build_dir): def copy_assets(js_dir, style_dir, build_dir):
for folder in [js_dir, style_dir]: for folder in [js_dir, style_dir]: