6 Commits
Author SHA1 Message Date
lumeex-ci c8f42b429a Bump build version to 2.1.5 [skip ci] 2026-08-26 13:41:23 +00:00
Djeex f22634e9d9 CI/CD hardening: lint, secret scan, coverage gate, auto CVE-fix PRs, GHCR + GitHub mirror publishing (#34)
CI / build-and-scan (push) Successful in 2m43s
- release changelog: commits rendered as description (link), divider lines dropped
- gitleaks secret scan and hadolint on every push/PR
- ruff lint/format gate (Python repos) with a pytest --cov-fail-under gate
- scheduled CRITICAL Trivy failures attempt an apk upgrade rebuild and open a follow-up PR if it clears the finding, instead of just failing red
- images also published to ghcr.io/djeex/<repo>
- a matching GitHub Release is created on the GitHub mirror, with a notice pointing back to this repo as the source of truth
2026-08-26 15:38:54 +02:00
lumeex-ci edd39febd1 Bump build version to 2.1.4 [skip ci] 2026-08-24 09:03:52 +00:00
Djeex 33766f0b0b Merge pull request 'Update python Docker tag to v3.14.7' (#32) from renovate/python-3.x into main
CI / build-and-scan (push) Successful in 1m39s
Reviewed-on: #32
2026-08-24 11:02:16 +02:00
Djeex f954ace53b Update python Docker tag to v3.14.7
CI / build-and-scan (pull_request) Successful in 1m7s
2026-08-24 11:00:55 +02:00
Djeex 6df2d561d9 Update dependency pytest-cov to v7.1.0
CI / build-and-scan (pull_request) Successful in 1m31s
CI / build-and-scan (push) Successful in 1m36s
2026-08-24 03:03:26 +00:00
20 changed files with 356 additions and 113 deletions
+100 -4
View File
@@ -18,6 +18,21 @@ jobs:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false
- name: Scan for secrets
run: |
# docker cp, not a build COPY: a repo's own .dockerignore (e.g. one that
# excludes .git for prod builds) would otherwise silently give an empty,
# falsely-clean scan.
CID=$(docker create zricethezav/gitleaks:v8.30.1 detect --source=/repo --no-banner -v)
docker cp . "$CID:/repo"
docker start -a "$CID"
STATUS=$?
docker rm "$CID" > /dev/null
exit $STATUS
- name: Lint Dockerfile with hadolint
run: docker run --rm -i hadolint/hadolint:v2.15.1-alpine hadolint --failure-threshold error - < Dockerfile
- name: Build Docker image - name: Build Docker image
run: | run: |
docker build -t lumeex:ci . 2>&1 | tee build.log docker build -t lumeex:ci . 2>&1 | tee build.log
@@ -46,7 +61,10 @@ jobs:
- name: Run unit tests - name: Run unit tests
run: | run: |
docker build --target test -t lumeex:test . docker build --target test -t lumeex:test .
docker run --rm lumeex:test pytest -v docker run --rm lumeex:test pytest -v --cov=. --cov-report=term-missing --cov-fail-under=90
- name: Lint with ruff
run: docker build --target lint -t lumeex:lint .
- name: Check deprecation warnings - name: Check deprecation warnings
run: | run: |
@@ -56,12 +74,53 @@ jobs:
fi fi
- name: Scan with Trivy (critical - blocking) - name: Scan with Trivy (critical - blocking)
id: trivy_critical
continue-on-error: true
run: | run: |
docker run --rm \ docker run --rm \
-e DOCKER_HOST=tcp://dockerhost:2375 \ -e DOCKER_HOST=tcp://dockerhost:2375 \
--add-host=dockerhost:host-gateway \ --add-host=dockerhost:host-gateway \
aquasec/trivy:0.74.0 image --exit-code 1 --severity CRITICAL lumeex:ci aquasec/trivy:0.74.0 image --exit-code 1 --severity CRITICAL lumeex:ci
- name: Handle CRITICAL findings
if: steps.trivy_critical.outcome == 'failure'
run: |
if [ "${{ github.event_name }}" != "schedule" ]; then
echo "::error::CRITICAL vulnerabilities found, failing the build."
exit 1
fi
echo "Scheduled scan found CRITICAL vulnerabilities — attempting an automatic apk upgrade + rescan."
sed -i '/^FROM .* AS base$/a RUN apk upgrade --no-cache' Dockerfile
docker build -t lumeex:remediated .
if docker run --rm \
-e DOCKER_HOST=tcp://dockerhost:2375 \
--add-host=dockerhost:host-gateway \
aquasec/trivy:0.74.0 image --exit-code 1 --severity CRITICAL lumeex:remediated; then
echo "apk upgrade clears the CRITICAL finding(s) — opening a PR for review."
BRANCH="auto/cve-fix-$(date +%Y%m%d)-$(echo "${{ github.sha }}" | cut -c1-7)"
git config user.name "lumeex-ci"
git config user.email "[email protected]"
git checkout -b "$BRANCH"
git add Dockerfile
git commit -m "Auto-remediate CRITICAL CVE via apk upgrade"
git config --unset-all http.https://git.djeex.fr/.extraheader || true
git push "https://Djeex:${{ secrets.CI_PUSH_TOKEN }}@git.djeex.fr/Djeex/lumeex.git" "HEAD:$BRANCH"
PR_JSON=$(curl -s -X POST \
-H "Authorization: token ${{ secrets.CI_PUSH_TOKEN }}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg head "$BRANCH" '{title: "🔒 Auto: remediate CRITICAL CVE via apk upgrade", head: $head, base: "main", body: "Opened automatically by the scheduled CVE scan. An `apk upgrade --no-cache` cleared the CRITICAL Trivy finding(s) in a rebuild — review the diff and merge to publish the fix."}')" \
"https://git.djeex.fr/api/v1/repos/Djeex/lumeex/pulls")
echo "PR API response: $(echo "$PR_JSON" | jq -r '.html_url // .message // "unknown"')"
else
echo "::error::apk upgrade does not clear the CRITICAL finding(s) — no automatic fix available, needs manual review."
exit 1
fi
- name: Scan with Trivy (high - informative) - name: Scan with Trivy (high - informative)
run: | run: |
docker run --rm \ docker run --rm \
@@ -126,6 +185,16 @@ jobs:
docker push "$IMAGE:$MINOR_TAG" docker push "$IMAGE:$MINOR_TAG"
docker push "$IMAGE:$VERSION" docker push "$IMAGE:$VERSION"
GHCR_IMAGE=ghcr.io/djeex/lumeex
echo "${{ secrets.GH_TOKEN }}" | docker login ghcr.io -u Djeex --password-stdin
docker tag lumeex:ci "$GHCR_IMAGE:latest"
docker tag lumeex:ci "$GHCR_IMAGE:$MINOR_TAG"
docker tag lumeex:ci "$GHCR_IMAGE:$VERSION"
docker push "$GHCR_IMAGE:latest"
docker push "$GHCR_IMAGE:$MINOR_TAG"
docker push "$GHCR_IMAGE:$VERSION"
TRIGGER_MSG=$(git log -1 --format=%s "${{ github.sha }}") TRIGGER_MSG=$(git log -1 --format=%s "${{ github.sha }}")
PR_NUM=$(echo "$TRIGGER_MSG" | grep -oE '#[0-9]+' | head -1 | tr -d '#' || true) PR_NUM=$(echo "$TRIGGER_MSG" | grep -oE '#[0-9]+' | head -1 | tr -d '#' || true)
@@ -152,7 +221,7 @@ jobs:
fi fi
REPO_URL="https://git.djeex.fr/Djeex/lumeex" REPO_URL="https://git.djeex.fr/Djeex/lumeex"
COMMIT_LIST=$(git log --no-merges --format="- [%h](${REPO_URL}/commit/%H) %s" "$BASE_REF".."${{ github.sha }}") COMMIT_LIST=$(git log --no-merges --format="- %s ([%h](${REPO_URL}/commit/%H))" "$BASE_REF".."${{ github.sha }}")
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
SOURCE_LINE="[${SHORT_SHA}](${REPO_URL}/commit/${{ github.sha }})" SOURCE_LINE="[${SHORT_SHA}](${REPO_URL}/commit/${{ github.sha }})"
@@ -162,10 +231,8 @@ jobs:
BODY=$(cat <<EOF BODY=$(cat <<EOF
## Changelog ## Changelog
---
### ${CATEGORY} ### ${CATEGORY}
---
${CHANGE_TITLE} ${CHANGE_TITLE}
**Source:** ${SOURCE_LINE} **Source:** ${SOURCE_LINE}
@@ -187,3 +254,32 @@ jobs:
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "$JSON_PAYLOAD" \ -d "$JSON_PAYLOAD" \
"https://git.djeex.fr/api/v1/repos/Djeex/lumeex/releases" "https://git.djeex.fr/api/v1/repos/Djeex/lumeex/releases"
MIRROR_NOTICE="_This github repo is a mirror of https://git.djeex.fr/Djeex/lumeex. You'll find full package, PR, history and release note there._"
GH_BODY=$(printf '%s\n\n%s' "$MIRROR_NOTICE" "$BODY")
GH_JSON_PAYLOAD=$(jq -n \
--arg tag "$VERSION" \
--arg name "$VERSION" \
--arg body "$GH_BODY" \
--arg sha "${{ github.sha }}" \
'{tag_name: $tag, name: $name, target_commitish: $sha, body: $body}')
GH_STATUS=0
for i in 1 2 3 4 5; do
GH_STATUS=$(curl -s -o /tmp/gh_release.json -w "%{http_code}" -X POST \
-H "Authorization: Bearer ${{ secrets.GH_TOKEN }}" \
-H "Accept: application/vnd.github+json" \
-H "Content-Type: application/json" \
-d "$GH_JSON_PAYLOAD" \
"https://api.github.com/repos/Djeex/lumeex/releases")
if [ "$GH_STATUS" = "201" ]; then
break
fi
echo "GitHub Release attempt $i failed (HTTP $GH_STATUS) — mirror may not have synced this commit yet, retrying in 15s..."
sleep 15
done
echo "GitHub Release API response: $GH_STATUS"
if [ "$GH_STATUS" != "201" ]; then
cat /tmp/gh_release.json 2>/dev/null || true
fi
+7
View File
@@ -27,6 +27,13 @@ RUN pip install --no-cache-dir -r requirements-dev.txt
COPY ./tests/ ./tests/ COPY ./tests/ ./tests/
COPY ./demo/ ./demo/ COPY ./demo/ ./demo/
FROM base AS lint
RUN pip install --no-cache-dir ruff==0.16.4
COPY ruff.toml /app/ruff.toml
COPY ./tests/ ./tests/
RUN ruff check . && ruff format --check .
FROM base FROM base
ENTRYPOINT ["/app/entrypoint.sh"] ENTRYPOINT ["/app/entrypoint.sh"]
+1 -1
View File
@@ -1 +1 @@
2.1.3 2.1.5
+2 -1
View File
@@ -1,6 +1,7 @@
import logging import logging
from src.py.builder.site_builder import build from src.py.builder.site_builder import build
if __name__ == "__main__": if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(message)s") logging.basicConfig(level=logging.INFO, format="%(message)s")
build() build()
+2 -1
View File
@@ -1,7 +1,8 @@
import logging import logging
from src.py.builder.gallery_builder import update_gallery, update_hero from src.py.builder.gallery_builder import update_gallery, update_hero
if __name__ == "__main__": if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(message)s") logging.basicConfig(level=logging.INFO, format="%(message)s")
update_gallery() update_gallery()
update_hero() update_hero()
+1 -1
View File
@@ -1,3 +1,3 @@
-r requirements.txt -r requirements.txt
pytest==8.4.2 pytest==8.4.2
pytest-cov==7.0.0 pytest-cov==7.1.0
+15
View File
@@ -0,0 +1,15 @@
line-length = 100
[lint]
select = ["E", "F", "I", "UP", "B"]
ignore = ["E501"]
[lint.isort]
# See nvidia-stock-bot's ruff.toml for why this is pinned explicitly rather
# than left to auto-detection.
known-first-party = ["src", "build", "gallery"]
[lint.per-file-ignores]
# Real findings (unused var, unused loop var) but app-logic changes,
# left for the user to decide rather than auto-fixed.
"src/py/webui/webui.py" = ["F841", "B007"]
+9 -3
View File
@@ -1,7 +1,7 @@
import logging import logging
from pathlib import Path
from shutil import copyfile from shutil import copyfile
def generate_css_variables(colors_dict, output_path): def generate_css_variables(colors_dict, output_path):
"""Generate css variables for theme colors""" """Generate css variables for theme colors"""
css_lines = [":root {"] css_lines = [":root {"]
@@ -13,6 +13,7 @@ def generate_css_variables(colors_dict, output_path):
f.write("\n".join(css_lines)) f.write("\n".join(css_lines))
logging.info(f"[✓] CSS variables written to {output_path}") logging.info(f"[✓] CSS variables written to {output_path}")
def generate_fonts_css(fonts_dir, output_path, fonts_cfg=None): def generate_fonts_css(fonts_dir, output_path, fonts_cfg=None):
"""Generate css variables fonts""" """Generate css variables fonts"""
font_files = list(fonts_dir.glob("*")) font_files = list(fonts_dir.glob("*"))
@@ -35,7 +36,7 @@ def generate_fonts_css(fonts_dir, output_path, fonts_cfg=None):
css_lines = [] css_lines = []
for font_name, sources in font_faces.items(): for font_name, sources in font_faces.items():
css_lines.append(f"@font-face {{") css_lines.append("@font-face {")
css_lines.append(f" font-family: '{font_name}';") css_lines.append(f" font-family: '{font_name}';")
srcs = [f"url('../fonts/{file}') format('{fmt}')" for file, fmt in sorted(sources)] srcs = [f"url('../fonts/{file}') format('{fmt}')" for file, fmt in sorted(sources)]
css_lines.append(f" src: {', '.join(srcs)};") css_lines.append(f" src: {', '.join(srcs)};")
@@ -58,6 +59,7 @@ def generate_fonts_css(fonts_dir, output_path, fonts_cfg=None):
logging.info(f"[✓] Generated fonts CSS: {output_path}") logging.info(f"[✓] Generated fonts CSS: {output_path}")
return preload_links return preload_links
def generate_google_fonts_link(fonts): def generate_google_fonts_link(fonts):
"""Generate src link for Google fonts""" """Generate src link for Google fonts"""
if not fonts: if not fonts:
@@ -70,5 +72,9 @@ def generate_google_fonts_link(fonts):
families.append(f"{family}:wght@{';'.join(weights)}") families.append(f"{family}:wght@{';'.join(weights)}")
else: else:
families.append(family) families.append(family)
href = "https://fonts.googleapis.com/css2?" + "&".join(f"family={f}" for f in families) + "&display=swap" href = (
"https://fonts.googleapis.com/css2?"
+ "&".join(f"family={f}" for f in families)
+ "&display=swap"
)
return f'<link href="{href}" rel="stylesheet">' return f'<link href="{href}" rel="stylesheet">'
+17 -17
View File
@@ -1,7 +1,8 @@
import yaml
import os import os
from pathlib import Path from pathlib import Path
import yaml
# YAML file paths # YAML file paths
GALLERY_YAML = "config/gallery.yaml" GALLERY_YAML = "config/gallery.yaml"
@@ -9,31 +10,37 @@ GALLERY_YAML = "config/gallery.yaml"
GALLERY_DIR = Path("config/photos/gallery") GALLERY_DIR = Path("config/photos/gallery")
HERO_DIR = Path("config/photos/hero") HERO_DIR = Path("config/photos/hero")
def load_yaml(path): def load_yaml(path):
"""Load gallery config .yaml file""" """Load gallery config .yaml file"""
print(f"[→] Loading {path}...") print(f"[→] Loading {path}...")
if not os.path.exists(path): if not os.path.exists(path):
print(f"[✗] File not found: {path}") print(f"[✗] File not found: {path}")
return {} return {}
with open(path, "r", encoding="utf-8") as f: with open(path, encoding="utf-8") as f:
data = yaml.safe_load(f) or {} data = yaml.safe_load(f) or {}
images = data.get("images", []) or [] images = data.get("images", []) or []
print(f"[✓] Loaded {len(images)} image(s) from {path}") print(f"[✓] Loaded {len(images)} image(s) from {path}")
return data return data
def save_yaml(data, path): def save_yaml(data, path):
"""Save modified gallery config .yaml file""" """Save modified gallery config .yaml file"""
with open(path, "w", encoding="utf-8") as f: with open(path, "w", encoding="utf-8") as f:
yaml.dump(data, f, sort_keys=False, allow_unicode=True) yaml.dump(data, f, sort_keys=False, allow_unicode=True)
print(f"[✓] Saved updated YAML to {path}") print(f"[✓] Saved updated YAML to {path}")
def get_all_image_paths(directory): def get_all_image_paths(directory):
"""Get the path to record for builded site""" """Get the path to record for builded site"""
return sorted([ return sorted(
str(p.relative_to(directory.parent)).replace("\\", "/") [
for p in directory.rglob("*") str(p.relative_to(directory.parent)).replace("\\", "/")
if p.suffix.lower() in [".jpg", ".jpeg", ".png", ".webp"] for p in directory.rglob("*")
]) if p.suffix.lower() in [".jpg", ".jpeg", ".png", ".webp"]
]
)
def update_gallery(): def update_gallery():
"""Update the gallery photo list""" """Update the gallery photo list"""
@@ -50,11 +57,7 @@ def update_gallery():
known_images = {img["src"] for img in gallery_images} known_images = {img["src"] for img in gallery_images}
# Add new images # Add new images
new_images = [ new_images = [{"src": path, "tags": []} for path in all_images if path not in known_images]
{"src": path, "tags": []}
for path in all_images
if path not in known_images
]
if new_images: if new_images:
gallery_images.extend(new_images) gallery_images.extend(new_images)
print(f"[✓] Added {len(new_images)} new image(s) to gallery.yaml (gallery)") print(f"[✓] Added {len(new_images)} new image(s) to gallery.yaml (gallery)")
@@ -74,6 +77,7 @@ def update_gallery():
if not new_images and not deleted_images: if not new_images and not deleted_images:
print("[✓] No changes to gallery.yaml (gallery)") print("[✓] No changes to gallery.yaml (gallery)")
def update_hero(): def update_hero():
"""Update the hero photo list""" """Update the hero photo list"""
print("\n=== Updating gallery.yaml (hero section) ===") print("\n=== Updating gallery.yaml (hero section) ===")
@@ -89,11 +93,7 @@ def update_hero():
known_images = {img["src"] for img in hero_images} known_images = {img["src"] for img in hero_images}
# Add new images # Add new images
new_images = [ new_images = [{"src": path} for path in all_images if path not in known_images]
{"src": path}
for path in all_images
if path not in known_images
]
if new_images: if new_images:
hero_images.extend(new_images) hero_images.extend(new_images)
print(f"[✓] Added {len(new_images)} new image(s) to gallery.yaml (hero)") print(f"[✓] Added {len(new_images)} new image(s) to gallery.yaml (hero)")
+7 -2
View File
@@ -2,6 +2,7 @@ import json
import logging import logging
from pathlib import Path from pathlib import Path
def render_template(template_path, context): def render_template(template_path, context):
"""Render html templates""" """Render html templates"""
with open(template_path, encoding="utf-8") as f: with open(template_path, encoding="utf-8") as f:
@@ -11,6 +12,7 @@ def render_template(template_path, context):
content = content.replace(placeholder, str(value) if value is not None else "") content = content.replace(placeholder, str(value) if value is not None else "")
return content return content
def render_gallery_images(images): def render_gallery_images(images):
"""Render the photo gallery""" """Render the photo gallery"""
html = "" html = ""
@@ -20,11 +22,12 @@ def render_gallery_images(images):
html += f""" html += f"""
<div class="section" data-tags="{tags}"> <div class="section" data-tags="{tags}">
<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>
""" """
return html return html
def generate_gallery_json_from_images(images, output_dir): def generate_gallery_json_from_images(images, output_dir):
"""Generte the hero carrousel photo list""" """Generte the hero carrousel photo list"""
try: try:
@@ -37,6 +40,7 @@ def generate_gallery_json_from_images(images, output_dir):
except Exception as e: except Exception as e:
logging.error(f"[✗] Error generating gallery JSON: {e}") logging.error(f"[✗] Error generating gallery JSON: {e}")
def generate_robots_txt(canonical_url, allowed_paths, output_dir): def generate_robots_txt(canonical_url, allowed_paths, output_dir):
"""Generate the robot.txt""" """Generate the robot.txt"""
robots_lines = ["User-agent: *"] robots_lines = ["User-agent: *"]
@@ -65,10 +69,11 @@ def generate_robots_txt(canonical_url, allowed_paths, output_dir):
except Exception as e: except Exception as e:
logging.error(f"[✗] Failed to write robots.txt: {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):
"""Generate the sitemap""" """Generate the sitemap"""
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'
urlset_end = '</urlset>\n' urlset_end = "</urlset>\n"
urls = "" urls = ""
for path in allowed_paths: for path in allowed_paths:
loc = canonical_url.rstrip("/") + path loc = canonical_url.rstrip("/") + path
+15 -4
View File
@@ -1,8 +1,10 @@
import logging import logging
from pathlib import Path from pathlib import Path
from PIL import Image, features
from shutil import copyfile from shutil import copyfile
from PIL import Image, features
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."""
try: try:
@@ -37,6 +39,7 @@ def convert_and_resize_image(input_path, output_path, resize=True, max_width=114
except Exception as e: except Exception as e:
logging.error(f"[✗] Error processing image {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.""" """Process a list of image references and update paths to optimized versions."""
for img in images: for img in images:
@@ -52,6 +55,7 @@ def process_images(images, resize_images, img_dir, build_dir):
if jpg_path.exists(): if jpg_path.exists():
img["src"] = str(Path(img["src"]).with_suffix(".jpg")) 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.""" """Copy original image files without processing."""
for img in images: for img in images:
@@ -70,6 +74,7 @@ def copy_original_images(images, img_dir, build_dir):
except Exception as e: except Exception as e:
logging.error(f"[✗] Error copying {src_path}: {e}") logging.error(f"[✗] Error copying {src_path}: {e}")
def get_favicon_path(theme_vars, theme_dir): def get_favicon_path(theme_vars, theme_dir):
"""Retrieve the favicon path from theme variables, ensuring it exists.""" """Retrieve the favicon path from theme variables, ensuring it exists."""
fav_path = theme_vars.get("favicon", {}).get("path") fav_path = theme_vars.get("favicon", {}).get("path")
@@ -87,6 +92,7 @@ def get_favicon_path(theme_vars, theme_dir):
return path 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.""" """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)
@@ -97,9 +103,13 @@ def generate_favicons_from_logo(theme_vars, theme_dir, output_dir):
try: try:
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
specs = [ specs = [
(32, "favicon-32.png"), (96, "favicon-96.png"), (128, "favicon-128.png"), (32, "favicon-32.png"),
(192, "favicon-192.png"), (196, "favicon-196.png"), (96, "favicon-96.png"),
(152, "favicon-152.png"), (180, "favicon-180.png") (128, "favicon-128.png"),
(192, "favicon-192.png"),
(196, "favicon-196.png"),
(152, "favicon-152.png"),
(180, "favicon-180.png"),
] ]
img = Image.open(logo_path).convert("RGBA") img = Image.open(logo_path).convert("RGBA")
for size, name in specs: for size, name in specs:
@@ -110,6 +120,7 @@ def generate_favicons_from_logo(theme_vars, theme_dir, output_dir):
except Exception as e: except Exception as e:
logging.error(f"[✗] Error generating PNG favicons: {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.""" """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)
+34 -13
View File
@@ -2,14 +2,27 @@ import logging
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from shutil import copyfile from shutil import copyfile
from PIL import Image 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 .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 (
from .html_generator import render_template, render_gallery_images, generate_gallery_json_from_images, generate_robots_txt, generate_sitemap_xml generate_gallery_json_from_images,
generate_robots_txt,
generate_sitemap_xml,
render_gallery_images,
render_template,
)
from .image_processor import (
copy_original_images,
generate_favicon_ico,
generate_favicons_from_logo,
process_images,
)
from .utils import copy_assets, ensure_dir, load_theme_config, load_yaml
# Configure logging to display only the messages # Configure logging to display only the messages
logging.basicConfig(level=logging.INFO, format='%(message)s') logging.basicConfig(level=logging.INFO, format="%(message)s")
# Define key directories used throughout the script # Define key directories used throughout the script
SRC_DIR = Path.cwd() SRC_DIR = Path.cwd()
@@ -22,9 +35,10 @@ GALLERY_FILE = SRC_DIR / "config/gallery.yaml"
SITE_FILE = SRC_DIR / "config/site.yaml" SITE_FILE = SRC_DIR / "config/site.yaml"
THEMES_DIR = SRC_DIR / "config/themes" THEMES_DIR = SRC_DIR / "config/themes"
VERSION_FILE = SRC_DIR / "VERSION" VERSION_FILE = SRC_DIR / "VERSION"
with open(VERSION_FILE, "r") as vf: with open(VERSION_FILE) as vf:
build_version = vf.read().strip() build_version = vf.read().strip()
def build(): def build():
logging.info("\n") logging.info("\n")
logging.info("=" * 24) logging.info("=" * 24)
@@ -33,7 +47,7 @@ def build():
logging.info("\n === Starting build === ") logging.info("\n === Starting build === ")
ensure_dir(BUILD_DIR) ensure_dir(BUILD_DIR)
copy_assets(JS_DIR, STYLE_DIR, BUILD_DIR) copy_assets(JS_DIR, STYLE_DIR, BUILD_DIR)
# Defining build vars # Defining build vars
build_date = datetime.now().strftime("%Y%m%d%H%M%S") build_date = datetime.now().strftime("%Y%m%d%H%M%S")
build_date_version = datetime.now().strftime("%Y-%m-%d %H:%M:%S") build_date_version = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
@@ -57,9 +71,13 @@ def build():
logging.info(f"[✓] Theme CSS found, copied to build folder: {dest_theme_css}") logging.info(f"[✓] Theme CSS found, copied to build folder: {dest_theme_css}")
else: else:
theme_css = "" theme_css = ""
logging.warning(f"[~] No theme.css found in {theme_css_path}, skipping theme CSS injection.") 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")) 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_css_variables(theme_vars.get("colors", {}), BUILD_DIR / "style" / "colors.css")
generate_favicons_from_logo(theme_vars, theme_dir, BUILD_DIR / "img" / "favicon") generate_favicons_from_logo(theme_vars, theme_dir, BUILD_DIR / "img" / "favicon")
generate_favicon_ico(theme_vars, theme_dir, BUILD_DIR / "favicon.ico") generate_favicon_ico(theme_vars, theme_dir, BUILD_DIR / "favicon.ico")
@@ -94,7 +112,7 @@ def build():
# Adding Google fonts if existing # Adding Google fonts if existing
google_fonts_link = generate_google_fonts_link(theme_vars.get("google_fonts", [])) google_fonts_link = generate_google_fonts_link(theme_vars.get("google_fonts", []))
logging.info(f"[✓] Google Fonts link generated") logging.info("[✓] Google Fonts link generated")
# Generating thumbnail # Generating thumbnail
thumbnail_path = site_vars.get("social", {}).get("thumbnail") thumbnail_path = site_vars.get("social", {}).get("thumbnail")
@@ -124,11 +142,13 @@ def build():
head_vars["theme_css"] = theme_css head_vars["theme_css"] = theme_css
head_vars["build_date"] = build_date head_vars["build_date"] = build_date
head_vars["canonical"] = canonical_home head_vars["canonical"] = canonical_home
# Render the home page # Render the home page
head = render_template(TEMPLATE_DIR / "head.html", head_vars) head = render_template(TEMPLATE_DIR / "head.html", head_vars)
hero = render_template(TEMPLATE_DIR / "hero.html", {**site_vars["hero"], **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}) footer = render_template(
TEMPLATE_DIR / "footer.html", {**site_vars.get("footer", {}), **head_vars}
)
gallery_html = render_gallery_images(gallery_images) gallery_html = render_gallery_images(gallery_images)
gallery = render_template(TEMPLATE_DIR / "gallery.html", {"gallery_images": gallery_html}) gallery = render_template(TEMPLATE_DIR / "gallery.html", {"gallery_images": gallery_html})
@@ -185,7 +205,8 @@ def build():
generate_robots_txt(canonical_url, allowed_pages, BUILD_DIR) generate_robots_txt(canonical_url, allowed_pages, BUILD_DIR)
generate_sitemap_xml(canonical_url, allowed_pages, BUILD_DIR) generate_sitemap_xml(canonical_url, allowed_pages, BUILD_DIR)
else: else:
logging.warning("[~] No canonical URL found in site.yaml info section, skipping robots.txt and sitemap.xml generation.") logging.warning(
"[~] No canonical URL found in site.yaml info section, skipping robots.txt and sitemap.xml generation."
)
logging.info("✅ Build complete.") logging.info("✅ Build complete.")
+10 -4
View File
@@ -1,26 +1,30 @@
import yaml
import logging import logging
from pathlib import Path from pathlib import Path
from shutil import copytree, rmtree, copyfile from shutil import copytree, rmtree
import yaml
def load_yaml(path): def load_yaml(path):
"""Load gallery and site .yaml conf""" """Load gallery and site .yaml conf"""
if not path.exists(): if not path.exists():
logging.warning(f"[!] YAML file not found: {path}") logging.warning(f"[!] YAML file not found: {path}")
return {} return {}
with open(path, "r", encoding="utf-8") as f: with open(path, encoding="utf-8") as f:
return yaml.safe_load(f) return yaml.safe_load(f)
def load_theme_config(theme_name, themes_dir): def load_theme_config(theme_name, themes_dir):
"""Load theme.yaml""" """Load theme.yaml"""
theme_dir = themes_dir / theme_name theme_dir = themes_dir / theme_name
theme_config_path = theme_dir / "theme.yaml" theme_config_path = theme_dir / "theme.yaml"
if not theme_config_path.exists(): if not theme_config_path.exists():
raise FileNotFoundError(f"[✗] Theme config not found: {theme_config_path}") raise FileNotFoundError(f"[✗] Theme config not found: {theme_config_path}")
with open(theme_config_path, "r", encoding="utf-8") as f: with open(theme_config_path, encoding="utf-8") as f:
theme_vars = yaml.safe_load(f) theme_vars = yaml.safe_load(f)
return theme_vars, theme_dir return theme_vars, theme_dir
def clear_dir(path: Path): def clear_dir(path: Path):
"""Clear the output dir""" """Clear the output dir"""
if not path.exists(): if not path.exists():
@@ -32,6 +36,7 @@ def clear_dir(path: Path):
elif child.is_dir(): elif child.is_dir():
rmtree(child) rmtree(child)
def ensure_dir(path: Path): def ensure_dir(path: Path):
"""Create the output dir if it does not exist""" """Create the output dir if it does not exist"""
if not path.exists(): if not path.exists():
@@ -39,6 +44,7 @@ def ensure_dir(path: Path):
else: else:
clear_dir(path) clear_dir(path)
def copy_assets(js_dir, style_dir, build_dir): def copy_assets(js_dir, style_dir, build_dir):
"""Copy public assets to output dir""" """Copy public assets to output dir"""
for folder in [js_dir, style_dir]: for folder in [js_dir, style_dir]:
+7 -3
View File
@@ -1,7 +1,9 @@
import logging import logging
from pathlib import Path from pathlib import Path
from flask import Blueprint, request, current_app
from flask import Blueprint, current_app, request
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from src.py.builder.gallery_builder import update_gallery, update_hero from src.py.builder.gallery_builder import update_gallery, update_hero
# --- Create Flask blueprint for upload routes --- # --- Create Flask blueprint for upload routes ---
@@ -10,10 +12,12 @@ upload_bp = Blueprint("upload", __name__)
# --- Allowed file types --- # --- Allowed file types ---
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "webp"} ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "webp"}
def allowed_file(filename: str) -> bool: def allowed_file(filename: str) -> bool:
"""Check if the uploaded file has an allowed extension.""" """Check if the uploaded file has an allowed extension."""
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
def save_uploaded_file(file, folder: Path): def save_uploaded_file(file, folder: Path):
"""Save an uploaded file to the specified folder.""" """Save an uploaded file to the specified folder."""
folder.mkdir(parents=True, exist_ok=True) # Create folder if not exists folder.mkdir(parents=True, exist_ok=True) # Create folder if not exists
@@ -22,6 +26,7 @@ def save_uploaded_file(file, folder: Path):
logging.info(f"[✓] Uploaded {filename} to {folder}") logging.info(f"[✓] Uploaded {filename} to {folder}")
return filename return filename
@upload_bp.route("/api/<section>/upload", methods=["POST"]) @upload_bp.route("/api/<section>/upload", methods=["POST"])
def upload_photo(section: str): def upload_photo(section: str):
""" """
@@ -35,7 +40,7 @@ def upload_photo(section: str):
# Check if files are provided # Check if files are provided
if "files" not in request.files: if "files" not in request.files:
return {"error": "No files provided"}, 400 return {"error": "No files provided"}, 400
files = request.files.getlist("files") files = request.files.getlist("files")
if not files: if not files:
return {"error": "No selected files"}, 400 return {"error": "No selected files"}, 400
@@ -63,4 +68,3 @@ def upload_photo(section: str):
return {"status": "ok", "uploaded": uploaded} return {"status": "ok", "uploaded": uploaded}
return {"error": "No valid files uploaded"}, 400 return {"error": "No valid files uploaded"}, 400
+111 -41
View File
@@ -1,16 +1,27 @@
# --- Imports --- # --- Imports ---
import logging import logging
import yaml import os
import subprocess import subprocess
import zipfile import zipfile
import os
from pathlib import Path from pathlib import Path
import yaml
from flask import ( from flask import (
Flask, jsonify, request, send_from_directory, render_template, Flask,
send_file, after_this_request after_this_request,
jsonify,
render_template,
request,
send_file,
send_from_directory,
) )
from src.py.builder.gallery_builder import ( from src.py.builder.gallery_builder import (
GALLERY_YAML, load_yaml, save_yaml, update_gallery, update_hero GALLERY_YAML,
load_yaml,
save_yaml,
update_gallery,
update_hero,
) )
from src.py.webui.upload import upload_bp from src.py.webui.upload import upload_bp
@@ -19,16 +30,11 @@ logging.basicConfig(level=logging.INFO, format="%(message)s")
# --- Flask app setup --- # --- Flask app setup ---
VERSION_FILE = Path(__file__).resolve().parents[3] / "VERSION" VERSION_FILE = Path(__file__).resolve().parents[3] / "VERSION"
with open(VERSION_FILE, "r") as vf: with open(VERSION_FILE) as vf:
lumeex_version = vf.read().strip() lumeex_version = vf.read().strip()
WEBUI_PATH = Path(__file__).parents[2] / "webui" # Path to static/templates WEBUI_PATH = Path(__file__).parents[2] / "webui" # Path to static/templates
app = Flask( app = Flask(__name__, template_folder=WEBUI_PATH, static_folder=WEBUI_PATH, static_url_path="")
__name__,
template_folder=WEBUI_PATH,
static_folder=WEBUI_PATH,
static_url_path=""
)
WEBUI_PORT = int(os.getenv("WEBUI_PORT", 5000)) WEBUI_PORT = int(os.getenv("WEBUI_PORT", 5000))
@@ -40,26 +46,34 @@ app.config["PHOTOS_DIR"] = PHOTOS_DIR
# --- Register upload blueprint --- # --- Register upload blueprint ---
app.register_blueprint(upload_bp) app.register_blueprint(upload_bp)
# --- Theme editor helper functions --- # --- Theme editor helper functions ---
def get_theme_name(): def get_theme_name():
"""Get current theme name from site.yaml.""" """Get current theme name from site.yaml."""
site_yaml_path = Path(__file__).resolve().parents[3] / "config" / "site.yaml" site_yaml_path = Path(__file__).resolve().parents[3] / "config" / "site.yaml"
with open(site_yaml_path, "r") as f: with open(site_yaml_path) as f:
site_yaml = yaml.safe_load(f) site_yaml = yaml.safe_load(f)
return site_yaml.get("build", {}).get("theme", "modern") return site_yaml.get("build", {}).get("theme", "modern")
def get_theme_yaml(theme_name): def get_theme_yaml(theme_name):
"""Load theme.yaml for a given theme.""" """Load theme.yaml for a given theme."""
theme_yaml_path = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml" theme_yaml_path = (
with open(theme_yaml_path, "r") as f: Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
)
with open(theme_yaml_path) as f:
return yaml.safe_load(f) return yaml.safe_load(f)
def save_theme_yaml(theme_name, theme_yaml): def save_theme_yaml(theme_name, theme_yaml):
"""Save theme.yaml for a given theme.""" """Save theme.yaml for a given theme."""
theme_yaml_path = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml" theme_yaml_path = (
Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
)
with open(theme_yaml_path, "w") as f: with open(theme_yaml_path, "w") as f:
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True) yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
def get_local_fonts(theme_name): def get_local_fonts(theme_name):
"""List local font files for a theme.""" """List local font files for a theme."""
fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts" fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts"
@@ -67,21 +81,23 @@ def get_local_fonts(theme_name):
return [] return []
return [f.name for f in fonts_dir.glob("*") if f.is_file() and f.suffix in [".woff", ".woff2"]] return [f.name for f in fonts_dir.glob("*") if f.is_file() and f.suffix in [".woff", ".woff2"]]
# --- ROUTES --- # --- ROUTES ---
# --- Main page --- # --- Main page ---
@app.route("/") @app.route("/")
def index(): def index():
return render_template("index.html") return render_template("index.html")
PREVIEW_PORT = int(os.getenv("PREVIEW_PORT", 3000)) PREVIEW_PORT = int(os.getenv("PREVIEW_PORT", 3000))
@app.context_processor @app.context_processor
def inject_version(): def inject_version():
return dict( return dict(lumeex_version=lumeex_version, preview_port=PREVIEW_PORT)
lumeex_version=lumeex_version,
preview_port=PREVIEW_PORT
)
# --- Gallery & Hero API --- # --- Gallery & Hero API ---
@app.route("/gallery-editor") @app.route("/gallery-editor")
@@ -89,18 +105,21 @@ def gallery_editor():
"""Render gallery editor page.""" """Render gallery editor page."""
return render_template("gallery-editor/index.html") return render_template("gallery-editor/index.html")
@app.route("/api/gallery", methods=["GET"]) @app.route("/api/gallery", methods=["GET"])
def get_gallery(): def get_gallery():
"""Get gallery images.""" """Get gallery images."""
data = load_yaml(GALLERY_YAML) data = load_yaml(GALLERY_YAML)
return jsonify(data.get("gallery", {}).get("images", [])) return jsonify(data.get("gallery", {}).get("images", []))
@app.route("/api/hero", methods=["GET"]) @app.route("/api/hero", methods=["GET"])
def get_hero(): def get_hero():
"""Get hero images.""" """Get hero images."""
data = load_yaml(GALLERY_YAML) data = load_yaml(GALLERY_YAML)
return jsonify(data.get("hero", {}).get("images", [])) return jsonify(data.get("hero", {}).get("images", []))
@app.route("/api/gallery/update", methods=["POST"]) @app.route("/api/gallery/update", methods=["POST"])
def update_gallery_api(): def update_gallery_api():
"""Update gallery images.""" """Update gallery images."""
@@ -110,6 +129,7 @@ def update_gallery_api():
save_yaml(data, GALLERY_YAML) save_yaml(data, GALLERY_YAML)
return jsonify({"status": "ok"}) return jsonify({"status": "ok"})
@app.route("/api/hero/update", methods=["POST"]) @app.route("/api/hero/update", methods=["POST"])
def update_hero_api(): def update_hero_api():
"""Update hero images.""" """Update hero images."""
@@ -119,18 +139,21 @@ def update_hero_api():
save_yaml(data, GALLERY_YAML) save_yaml(data, GALLERY_YAML)
return jsonify({"status": "ok"}) return jsonify({"status": "ok"})
@app.route("/api/gallery/refresh", methods=["POST"]) @app.route("/api/gallery/refresh", methods=["POST"])
def refresh_gallery(): def refresh_gallery():
"""Refresh gallery images from disk.""" """Refresh gallery images from disk."""
update_gallery() update_gallery()
return jsonify({"status": "ok"}) return jsonify({"status": "ok"})
@app.route("/api/hero/refresh", methods=["POST"]) @app.route("/api/hero/refresh", methods=["POST"])
def refresh_hero(): def refresh_hero():
"""Refresh hero images from disk.""" """Refresh hero images from disk."""
update_hero() update_hero()
return jsonify({"status": "ok"}) return jsonify({"status": "ok"})
# --- Gallery & Hero photo deletion --- # --- Gallery & Hero photo deletion ---
@app.route("/api/gallery/delete", methods=["POST"]) @app.route("/api/gallery/delete", methods=["POST"])
def delete_gallery_photo(): def delete_gallery_photo():
@@ -143,6 +166,7 @@ def delete_gallery_photo():
return {"status": "ok"} return {"status": "ok"}
return {"error": "❌ File not found"}, 404 return {"error": "❌ File not found"}, 404
@app.route("/api/hero/delete", methods=["POST"]) @app.route("/api/hero/delete", methods=["POST"])
def delete_hero_photo(): def delete_hero_photo():
"""Delete a hero photo.""" """Delete a hero photo."""
@@ -154,6 +178,7 @@ def delete_hero_photo():
return {"status": "ok"} return {"status": "ok"}
return {"error": "❌ File not found"}, 404 return {"error": "❌ File not found"}, 404
@app.route("/api/gallery/delete_all", methods=["POST"]) @app.route("/api/gallery/delete_all", methods=["POST"])
def delete_all_gallery_photos(): def delete_all_gallery_photos():
"""Delete all gallery photos.""" """Delete all gallery photos."""
@@ -168,6 +193,7 @@ def delete_all_gallery_photos():
save_yaml(data, GALLERY_YAML) save_yaml(data, GALLERY_YAML)
return jsonify({"status": "ok", "deleted": deleted}) return jsonify({"status": "ok", "deleted": deleted})
@app.route("/api/hero/delete_all", methods=["POST"]) @app.route("/api/hero/delete_all", methods=["POST"])
def delete_all_hero_photos(): def delete_all_hero_photos():
"""Delete all hero photos.""" """Delete all hero photos."""
@@ -182,36 +208,41 @@ def delete_all_hero_photos():
save_yaml(data, GALLERY_YAML) save_yaml(data, GALLERY_YAML)
return jsonify({"status": "ok", "deleted": deleted}) return jsonify({"status": "ok", "deleted": deleted})
# --- Serve photos --- # --- Serve photos ---
@app.route("/photos/<section>/<path:filename>") @app.route("/photos/<section>/<path:filename>")
def photos(section, filename): def photos(section, filename):
"""Serve a photo from a section.""" """Serve a photo from a section."""
return send_from_directory(PHOTOS_DIR / section, filename) return send_from_directory(PHOTOS_DIR / section, filename)
@app.route("/photos/<path:filename>") @app.route("/photos/<path:filename>")
def serve_photo(filename): def serve_photo(filename):
"""Serve a photo from the photos directory.""" """Serve a photo from the photos directory."""
photos_dir = Path(__file__).resolve().parents[3] / "config" / "photos" photos_dir = Path(__file__).resolve().parents[3] / "config" / "photos"
return send_from_directory(photos_dir, filename) return send_from_directory(photos_dir, filename)
# --- Site info page & API --- # --- Site info page & API ---
@app.route("/site-info") @app.route("/site-info")
def site_info(): def site_info():
"""Render site info editor page.""" """Render site info editor page."""
return render_template("site-info/index.html") return render_template("site-info/index.html")
@app.route("/api/site-info", methods=["GET"]) @app.route("/api/site-info", methods=["GET"])
def get_site_info(): def get_site_info():
"""Get site info YAML as JSON.""" """Get site info YAML as JSON."""
with open(SITE_YAML, "r") as f: with open(SITE_YAML) as f:
data = yaml.safe_load(f) data = yaml.safe_load(f)
return jsonify(data) return jsonify(data)
@app.route("/api/site-info", methods=["POST"]) @app.route("/api/site-info", methods=["POST"])
def update_site_info(): def update_site_info():
"""Update site info YAML.""" """Update site info YAML."""
new_data = request.json new_data = request.json
with open(SITE_YAML, "r") as f: with open(SITE_YAML) as f:
old_data = yaml.safe_load(f) or {} old_data = yaml.safe_load(f) or {}
def deep_merge(old, new): def deep_merge(old, new):
@@ -227,6 +258,7 @@ def update_site_info():
yaml.safe_dump(merged, f, sort_keys=False, allow_unicode=True) yaml.safe_dump(merged, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok"}) return jsonify({"status": "ok"})
# --- Theme management --- # --- Theme management ---
@app.route("/api/themes") @app.route("/api/themes")
def list_themes(): def list_themes():
@@ -235,6 +267,7 @@ def list_themes():
themes = [d.name for d in themes_dir.iterdir() if d.is_dir()] themes = [d.name for d in themes_dir.iterdir() if d.is_dir()]
return jsonify(themes) return jsonify(themes)
# --- Thumbnail upload/remove --- # --- Thumbnail upload/remove ---
@app.route("/api/thumbnail/upload", methods=["POST"]) @app.route("/api/thumbnail/upload", methods=["POST"])
def upload_thumbnail(): def upload_thumbnail():
@@ -245,13 +278,14 @@ def upload_thumbnail():
return {"error": "❌ No file provided"}, 400 return {"error": "❌ No file provided"}, 400
filename = "thumbnail.png" filename = "thumbnail.png"
file.save(PHOTOS_DIR / filename) file.save(PHOTOS_DIR / filename)
with open(SITE_YAML, "r") as f: with open(SITE_YAML) as f:
data = yaml.safe_load(f) data = yaml.safe_load(f)
data.setdefault("social", {})["thumbnail"] = filename data.setdefault("social", {})["thumbnail"] = filename
with open(SITE_YAML, "w") as f: with open(SITE_YAML, "w") as f:
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True) yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok", "filename": filename}) return jsonify({"status": "ok", "filename": filename})
@app.route("/api/thumbnail/remove", methods=["POST"]) @app.route("/api/thumbnail/remove", methods=["POST"])
def remove_thumbnail(): def remove_thumbnail():
"""Remove thumbnail image and update site.yaml.""" """Remove thumbnail image and update site.yaml."""
@@ -259,7 +293,7 @@ def remove_thumbnail():
thumbnail_path = PHOTOS_DIR / "thumbnail.png" thumbnail_path = PHOTOS_DIR / "thumbnail.png"
if thumbnail_path.exists(): if thumbnail_path.exists():
thumbnail_path.unlink() thumbnail_path.unlink()
with open(SITE_YAML, "r") as f: with open(SITE_YAML) as f:
data = yaml.safe_load(f) data = yaml.safe_load(f)
if "social" in data and "thumbnail" in data["social"]: if "social" in data and "thumbnail" in data["social"]:
data["social"]["thumbnail"] = "" data["social"]["thumbnail"] = ""
@@ -267,6 +301,7 @@ def remove_thumbnail():
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True) yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok"}) return jsonify({"status": "ok"})
# --- Theme upload --- # --- Theme upload ---
@app.route("/api/theme/upload", methods=["POST"]) @app.route("/api/theme/upload", methods=["POST"])
def upload_theme(): def upload_theme():
@@ -286,6 +321,7 @@ def upload_theme():
file.save(dest_path) file.save(dest_path)
return jsonify({"status": "ok", "theme": folder_name}) return jsonify({"status": "ok", "theme": folder_name})
@app.route("/api/theme/remove", methods=["POST"]) @app.route("/api/theme/remove", methods=["POST"])
def remove_theme(): def remove_theme():
"""Remove a custom theme folder.""" """Remove a custom theme folder."""
@@ -302,15 +338,18 @@ def remove_theme():
return jsonify({"error": "❌ Cannot remove default theme"}), 400 return jsonify({"error": "❌ Cannot remove default theme"}), 400
# Remove folder and all contents # Remove folder and all contents
import shutil import shutil
shutil.rmtree(theme_folder) shutil.rmtree(theme_folder)
return jsonify({"status": "ok"}) return jsonify({"status": "ok"})
# --- Theme editor page & API --- # --- Theme editor page & API ---
@app.route("/theme-editor") @app.route("/theme-editor")
def theme_editor(): def theme_editor():
"""Render theme editor page.""" """Render theme editor page."""
return render_template("theme-editor/index.html") return render_template("theme-editor/index.html")
@app.route("/api/theme-info", methods=["GET", "POST"]) @app.route("/api/theme-info", methods=["GET", "POST"])
def api_theme_info(): def api_theme_info():
"""Get or update theme.yaml for current theme.""" """Get or update theme.yaml for current theme."""
@@ -318,11 +357,9 @@ def api_theme_info():
if request.method == "GET": if request.method == "GET":
theme_yaml = get_theme_yaml(theme_name) theme_yaml = get_theme_yaml(theme_name)
google_fonts = theme_yaml.get("google_fonts", []) google_fonts = theme_yaml.get("google_fonts", [])
return jsonify({ return jsonify(
"theme_name": theme_name, {"theme_name": theme_name, "theme_yaml": theme_yaml, "google_fonts": google_fonts}
"theme_yaml": theme_yaml, )
"google_fonts": google_fonts
})
else: else:
data = request.get_json() data = request.get_json()
theme_yaml = data.get("theme_yaml") theme_yaml = data.get("theme_yaml")
@@ -330,20 +367,24 @@ def api_theme_info():
save_theme_yaml(theme_name, theme_yaml) save_theme_yaml(theme_name, theme_yaml)
return jsonify({"status": "ok"}) return jsonify({"status": "ok"})
@app.route("/api/theme-google-fonts", methods=["POST"]) @app.route("/api/theme-google-fonts", methods=["POST"])
def update_theme_google_fonts(): def update_theme_google_fonts():
"""Update only google_fonts in theme.yaml for current theme.""" """Update only google_fonts in theme.yaml for current theme."""
data = request.get_json() data = request.get_json()
theme_name = data.get("theme_name") theme_name = data.get("theme_name")
google_fonts = data.get("google_fonts", []) google_fonts = data.get("google_fonts", [])
theme_yaml_path = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml" theme_yaml_path = (
with open(theme_yaml_path, "r") as f: Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
)
with open(theme_yaml_path) as f:
theme_yaml = yaml.safe_load(f) theme_yaml = yaml.safe_load(f)
theme_yaml["google_fonts"] = google_fonts theme_yaml["google_fonts"] = google_fonts
with open(theme_yaml_path, "w") as f: with open(theme_yaml_path, "w") as f:
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True) yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok"}) return jsonify({"status": "ok"})
@app.route("/api/local-fonts") @app.route("/api/local-fonts")
def api_local_fonts(): def api_local_fonts():
"""List local fonts for a theme.""" """List local fonts for a theme."""
@@ -351,6 +392,7 @@ def api_local_fonts():
fonts = get_local_fonts(theme_name) fonts = get_local_fonts(theme_name)
return jsonify(fonts) return jsonify(fonts)
# --- Favicon upload/remove --- # --- Favicon upload/remove ---
@app.route("/api/favicon/upload", methods=["POST"]) @app.route("/api/favicon/upload", methods=["POST"])
def upload_favicon(): def upload_favicon():
@@ -366,13 +408,14 @@ def upload_favicon():
theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name
file.save(theme_dir / filename) file.save(theme_dir / filename)
theme_yaml_path = theme_dir / "theme.yaml" theme_yaml_path = theme_dir / "theme.yaml"
with open(theme_yaml_path, "r") as f: with open(theme_yaml_path) as f:
theme_yaml = yaml.safe_load(f) theme_yaml = yaml.safe_load(f)
theme_yaml.setdefault("favicon", {})["path"] = filename theme_yaml.setdefault("favicon", {})["path"] = filename
with open(theme_yaml_path, "w") as f: with open(theme_yaml_path, "w") as f:
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True) yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok", "filename": filename}) return jsonify({"status": "ok", "filename": filename})
@app.route("/api/favicon/remove", methods=["POST"]) @app.route("/api/favicon/remove", methods=["POST"])
def remove_favicon(): def remove_favicon():
"""Remove favicon for a theme.""" """Remove favicon for a theme."""
@@ -386,7 +429,7 @@ def remove_favicon():
if favicon_path.exists(): if favicon_path.exists():
favicon_path.unlink() favicon_path.unlink()
theme_yaml_path = theme_dir / "theme.yaml" theme_yaml_path = theme_dir / "theme.yaml"
with open(theme_yaml_path, "r") as f: with open(theme_yaml_path) as f:
theme_yaml = yaml.safe_load(f) theme_yaml = yaml.safe_load(f)
if "favicon" in theme_yaml: if "favicon" in theme_yaml:
theme_yaml["favicon"]["path"] = "" theme_yaml["favicon"]["path"] = ""
@@ -394,6 +437,7 @@ def remove_favicon():
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True) yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok"}) return jsonify({"status": "ok"})
# --- Serve theme assets --- # --- Serve theme assets ---
@app.route("/themes/<theme>/<filename>") @app.route("/themes/<theme>/<filename>")
def serve_theme_asset(theme, filename): def serve_theme_asset(theme, filename):
@@ -401,6 +445,7 @@ def serve_theme_asset(theme, filename):
theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme
return send_from_directory(theme_dir, filename) return send_from_directory(theme_dir, filename)
# --- Font upload/remove --- # --- Font upload/remove ---
@app.route("/api/font/upload", methods=["POST"]) @app.route("/api/font/upload", methods=["POST"])
def upload_font(): def upload_font():
@@ -418,6 +463,7 @@ def upload_font():
font_basename = Path(file.filename).stem font_basename = Path(file.filename).stem
return jsonify({"status": "ok", "filename": font_basename}) return jsonify({"status": "ok", "filename": font_basename})
@app.route("/api/font/remove", methods=["POST"]) @app.route("/api/font/remove", methods=["POST"])
def remove_font(): def remove_font():
"""Remove a font file for a theme.""" """Remove a font file for a theme."""
@@ -433,6 +479,7 @@ def remove_font():
return jsonify({"status": "ok"}) return jsonify({"status": "ok"})
return jsonify({"error": "❌ Font not found"}), 404 return jsonify({"error": "❌ Font not found"}), 404
# --- Build & Download ZIP --- # --- Build & Download ZIP ---
@app.route("/api/build", methods=["POST"]) @app.route("/api/build", methods=["POST"])
def trigger_build(): def trigger_build():
@@ -446,7 +493,7 @@ def trigger_build():
if not site_yaml_path.exists(): if not site_yaml_path.exists():
return jsonify({"status": "error", "message": "❌ site.yaml not found"}), 400 return jsonify({"status": "error", "message": "❌ site.yaml not found"}), 400
with open(site_yaml_path, "r") as f: with open(site_yaml_path) as f:
site_data = yaml.safe_load(f) or {} site_data = yaml.safe_load(f) or {}
# Dynamically check all main sections and nested keys # Dynamically check all main sections and nested keys
@@ -454,24 +501,45 @@ def trigger_build():
for section in main_sections: for section in main_sections:
value = site_data.get(section) value = site_data.get(section)
if not value: if not value:
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}"}), 400 return jsonify(
{"status": "error", "message": f"❌ Site info are not set: missing {section}"}
), 400
if isinstance(value, dict): if isinstance(value, dict):
for k, v in value.items(): for k, v in value.items():
if v is None or v == "" or (isinstance(v, list) and not v): if v is None or v == "" or (isinstance(v, list) and not v):
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}.{k}"}), 400 return jsonify(
{
"status": "error",
"message": f"❌ Site info are not set: missing {section}.{k}",
}
), 400
elif isinstance(value, list): elif isinstance(value, list):
if not value: if not value:
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}"}), 400 return jsonify(
{"status": "error", "message": f"❌ Site info are not set: missing {section}"}
), 400
for idx, item in enumerate(value): for idx, item in enumerate(value):
if isinstance(item, dict): if isinstance(item, dict):
for k, v in item.items(): for k, v in item.items():
if v is None or v == "" or (isinstance(v, list) and not v): if v is None or v == "" or (isinstance(v, list) and not v):
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}[{idx}].{k}"}), 400 return jsonify(
{
"status": "error",
"message": f"❌ Site info are not set: missing {section}[{idx}].{k}",
}
), 400
elif item is None or item == "": elif item is None or item == "":
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}[{idx}]"}), 400 return jsonify(
{
"status": "error",
"message": f"❌ Site info are not set: missing {section}[{idx}]",
}
), 400
else: else:
if value is None or value == "": if value is None or value == "":
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}"}), 400 return jsonify(
{"status": "error", "message": f"❌ Site info are not set: missing {section}"}
), 400
try: try:
subprocess.run(["python3", "build.py"], check=True) subprocess.run(["python3", "build.py"], check=True)
@@ -479,6 +547,7 @@ def trigger_build():
except Exception as e: except Exception as e:
return jsonify({"status": "error", "message": f"{str(e)}"}), 500 return jsonify({"status": "error", "message": f"{str(e)}"}), 500
@app.route("/download-output-zip", methods=["POST"]) @app.route("/download-output-zip", methods=["POST"])
def download_output_zip(): def download_output_zip():
""" """
@@ -505,8 +574,9 @@ def download_output_zip():
return send_file(zip_path, as_attachment=True) return send_file(zip_path, as_attachment=True)
# --- Run server --- # --- Run server ---
if __name__ == "__main__": if __name__ == "__main__":
logging.info("[~] Starting WebUI at http://0.0.0.0:5000") logging.info("[~] Starting WebUI at http://0.0.0.0:5000")
logging.info(f"[i] WebUI host port is set to {WEBUI_PORT}") logging.info(f"[i] WebUI host port is set to {WEBUI_PORT}")
app.run(host="0.0.0.0", port=5000, debug=True) app.run(host="0.0.0.0", port=5000, debug=True)
+2 -5
View File
@@ -50,9 +50,7 @@ def app_env(tmp_path, monkeypatch, make_image):
theme_dir = root / "config" / "themes" / "modern" theme_dir = root / "config" / "themes" / "modern"
theme_dir.mkdir(parents=True) theme_dir.mkdir(parents=True)
(theme_dir / "theme.yaml").write_text( (theme_dir / "theme.yaml").write_text(
"colors:\n browser_color: '#ffffff'\n" "colors:\n browser_color: '#ffffff'\nfavicon:\n path: favicon.png\ngoogle_fonts: []\n",
"favicon:\n path: favicon.png\n"
"google_fonts: []\n",
encoding="utf-8", encoding="utf-8",
) )
make_image(theme_dir / "favicon.png", size=(32, 32), fmt="PNG") make_image(theme_dir / "favicon.png", size=(32, 32), fmt="PNG")
@@ -61,8 +59,7 @@ def app_env(tmp_path, monkeypatch, make_image):
"hero:\n images: []\ngallery:\n images: []\n", encoding="utf-8" "hero:\n images: []\ngallery:\n images: []\n", encoding="utf-8"
) )
(root / "config" / "site.yaml").write_text( (root / "config" / "site.yaml").write_text(
"info:\n title: Test\n canonical: https://example.com\n" "info:\n title: Test\n canonical: https://example.com\nsocial:\n thumbnail: ''\n",
"social:\n thumbnail: ''\n",
encoding="utf-8", encoding="utf-8",
) )
+1 -3
View File
@@ -1,5 +1,3 @@
from pathlib import Path
from src.py.builder import utils from src.py.builder import utils
@@ -30,7 +28,7 @@ def test_load_theme_config_missing_raises(tmp_path):
themes_dir = tmp_path / "themes" themes_dir = tmp_path / "themes"
try: try:
utils.load_theme_config("missing", themes_dir) utils.load_theme_config("missing", themes_dir)
assert False, "expected FileNotFoundError" raise AssertionError("expected FileNotFoundError")
except FileNotFoundError: except FileNotFoundError:
pass pass
-2
View File
@@ -1,5 +1,3 @@
import yaml
from src.py.builder import gallery_builder as gb from src.py.builder import gallery_builder as gb
+7 -4
View File
@@ -1,5 +1,3 @@
from pathlib import Path
from PIL import Image from PIL import Image
from src.py.builder import image_processor as ip from src.py.builder import image_processor as ip
@@ -101,8 +99,13 @@ def test_generate_favicons_from_logo_creates_all_sizes(tmp_path, make_image):
ip.generate_favicons_from_logo({"favicon": {"path": "favicon.png"}}, theme_dir, output_dir) ip.generate_favicons_from_logo({"favicon": {"path": "favicon.png"}}, theme_dir, output_dir)
expected = [ expected = [
"favicon-32.png", "favicon-96.png", "favicon-128.png", "favicon-32.png",
"favicon-192.png", "favicon-196.png", "favicon-152.png", "favicon-180.png", "favicon-96.png",
"favicon-128.png",
"favicon-192.png",
"favicon-196.png",
"favicon-152.png",
"favicon-180.png",
] ]
for name in expected: for name in expected:
assert (output_dir / name).exists() assert (output_dir / name).exists()
+8 -4
View File
@@ -102,7 +102,13 @@ def test_site_info_get_and_post(client, app_env):
data = resp.get_json() data = resp.get_json()
assert data["info"]["title"] == "Test" assert data["info"]["title"] == "Test"
client.post("/api/site-info", json={"info": {"subtitle": "New subtitle"}, "social": {"instagram_url": "https://insta.example"}}) client.post(
"/api/site-info",
json={
"info": {"subtitle": "New subtitle"},
"social": {"instagram_url": "https://insta.example"},
},
)
updated = client.get("/api/site-info").get_json() updated = client.get("/api/site-info").get_json()
assert updated["info"]["title"] == "Test" assert updated["info"]["title"] == "Test"
@@ -271,9 +277,7 @@ def test_trigger_build_success_invokes_subprocess(client, app_env, monkeypatch):
) )
calls = [] calls = []
monkeypatch.setattr( monkeypatch.setattr(webui.subprocess, "run", lambda *a, **k: calls.append((a, k)))
webui.subprocess, "run", lambda *a, **k: calls.append((a, k))
)
resp = client.post("/api/build") resp = client.post("/api/build")
assert resp.get_json() == {"status": "ok"} assert resp.get_json() == {"status": "ok"}