Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8453acfbe6 | ||
|
|
c8f42b429a | ||
|
|
f22634e9d9 | ||
|
|
edd39febd1 | ||
|
|
33766f0b0b | ||
|
|
f954ace53b | ||
|
|
6df2d561d9 | ||
|
|
18c894f780 | ||
|
|
ca4240c84a | ||
|
|
83d2dc7ee6 | ||
|
|
8f4d763048 | ||
|
|
d9b821c066 | ||
|
|
7130069adf | ||
|
|
142a48ad5e |
@@ -0,0 +1,285 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
schedule:
|
||||||
|
- cron: "0 6 * * 1"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-scan:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
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
|
||||||
|
run: |
|
||||||
|
docker build -t lumeex:ci . 2>&1 | tee build.log
|
||||||
|
if grep -q "Building wheel for" build.log; then
|
||||||
|
echo "::warning::A dependency was built from source — check Python/Alpine compatibility"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Smoke test (syntax check)
|
||||||
|
run: |
|
||||||
|
docker run --rm --entrypoint python lumeex:ci -c "
|
||||||
|
import ast
|
||||||
|
ok = True
|
||||||
|
for f in ('build.py', 'gallery.py'):
|
||||||
|
with open(f) as fh:
|
||||||
|
source = fh.read()
|
||||||
|
try:
|
||||||
|
ast.parse(source)
|
||||||
|
except SyntaxError as e:
|
||||||
|
print(f'::error::Syntax error in {f}: {e}')
|
||||||
|
ok = False
|
||||||
|
if not ok:
|
||||||
|
exit(1)
|
||||||
|
print('OK: syntax is valid')
|
||||||
|
"
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: |
|
||||||
|
docker build --target test -t lumeex:test .
|
||||||
|
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
|
||||||
|
run: |
|
||||||
|
docker run --rm --entrypoint python lumeex:ci -W error::DeprecationWarning -c "import src.py.webui.webui" 2>&1 | tee deprecation.log || true
|
||||||
|
if grep -qi "deprecat" deprecation.log; then
|
||||||
|
echo "::warning::Deprecation warning detected, check logs"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Scan with Trivy (critical - blocking)
|
||||||
|
id: trivy_critical
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
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: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)
|
||||||
|
run: |
|
||||||
|
docker run --rm \
|
||||||
|
-e DOCKER_HOST=tcp://dockerhost:2375 \
|
||||||
|
--add-host=dockerhost:host-gateway \
|
||||||
|
aquasec/trivy:0.74.0 image --exit-code 0 --severity HIGH lumeex:ci
|
||||||
|
|
||||||
|
- name: Publish tagged image
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
run: |
|
||||||
|
BEFORE="${{ github.event.before }}"
|
||||||
|
if [ -n "$BEFORE" ] && [ "$BEFORE" != "0000000000000000000000000000000000000000" ] && git cat-file -e "$BEFORE" 2>/dev/null; then
|
||||||
|
BASE_REF="$BEFORE"
|
||||||
|
else
|
||||||
|
BASE_REF="HEAD~1"
|
||||||
|
fi
|
||||||
|
CHANGED=$(git diff --name-only "$BASE_REF" "${{ github.sha }}")
|
||||||
|
echo "Changed files:"
|
||||||
|
echo "$CHANGED"
|
||||||
|
|
||||||
|
if ! echo "$CHANGED" | grep -qE '^(Dockerfile|requirements\.txt|VERSION|build\.py|gallery\.py)$|^(src|config)/|^docker/\.sh/entrypoint\.sh$'; then
|
||||||
|
echo "No container-relevant file changed, skipping publish."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if echo "$CHANGED" | grep -qE '^VERSION$'; then
|
||||||
|
echo "VERSION was manually edited in this push, using it as-is."
|
||||||
|
else
|
||||||
|
echo "VERSION untouched but container files changed, auto-bumping the build number (Z)."
|
||||||
|
OLD_VERSION=$(tr -d '[:space:]' < VERSION)
|
||||||
|
IFS='.' read -r MAJOR MINOR PATCH <<< "$OLD_VERSION"
|
||||||
|
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||||
|
echo "$NEW_VERSION" > VERSION
|
||||||
|
|
||||||
|
git config user.name "lumeex-ci"
|
||||||
|
git config user.email "[email protected]"
|
||||||
|
git add VERSION
|
||||||
|
git commit -m "Bump build version to $NEW_VERSION [skip ci]"
|
||||||
|
|
||||||
|
# Belt and suspenders: actions/checkout can leave its own ephemeral
|
||||||
|
# credential injected as an extraheader, which would silently override
|
||||||
|
# the URL-embedded token below. persist-credentials:false on checkout
|
||||||
|
# should already prevent this, but strip it here too just in case.
|
||||||
|
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:main
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION=$(tr -d '[:space:]' < VERSION)
|
||||||
|
IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION"
|
||||||
|
MINOR_TAG="${MAJOR}.${MINOR}"
|
||||||
|
|
||||||
|
IMAGE=git.djeex.fr/djeex/lumeex
|
||||||
|
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.djeex.fr -u Djeex --password-stdin
|
||||||
|
|
||||||
|
# Retag the already-built, already-scanned image — never rebuild for publish,
|
||||||
|
# so what ships is byte-for-byte what Trivy just scanned.
|
||||||
|
docker tag lumeex:ci "$IMAGE:latest"
|
||||||
|
docker tag lumeex:ci "$IMAGE:$MINOR_TAG"
|
||||||
|
docker tag lumeex:ci "$IMAGE:$VERSION"
|
||||||
|
docker push "$IMAGE:latest"
|
||||||
|
docker push "$IMAGE:$MINOR_TAG"
|
||||||
|
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 }}")
|
||||||
|
PR_NUM=$(echo "$TRIGGER_MSG" | grep -oE '#[0-9]+' | head -1 | tr -d '#' || true)
|
||||||
|
|
||||||
|
CATEGORY="🔧 Maintenance"
|
||||||
|
CHANGE_TITLE="$TRIGGER_MSG"
|
||||||
|
|
||||||
|
if [ -n "$PR_NUM" ]; then
|
||||||
|
PR_JSON=$(curl -s -H "Authorization: token ${{ secrets.CI_PUSH_TOKEN }}" \
|
||||||
|
"https://git.djeex.fr/api/v1/repos/Djeex/lumeex/pulls/$PR_NUM")
|
||||||
|
PR_TITLE=$(echo "$PR_JSON" | jq -r '.title // empty' 2>/dev/null || true)
|
||||||
|
LABELS=$(echo "$PR_JSON" | jq -r '.labels[]?.name' 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ -n "$PR_TITLE" ]; then
|
||||||
|
CHANGE_TITLE="$PR_TITLE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if echo "$LABELS" | grep -qx 'bug'; then
|
||||||
|
CATEGORY="⚠️ Hotfix"
|
||||||
|
elif echo "$LABELS" | grep -qx 'major'; then
|
||||||
|
CATEGORY="💥 Breaking change"
|
||||||
|
elif echo "$LABELS" | grep -qx 'minor'; then
|
||||||
|
CATEGORY="✨ Update"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
REPO_URL="https://git.djeex.fr/Djeex/lumeex"
|
||||||
|
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)
|
||||||
|
SOURCE_LINE="[${SHORT_SHA}](${REPO_URL}/commit/${{ github.sha }})"
|
||||||
|
if [ -n "$PR_NUM" ]; then
|
||||||
|
SOURCE_LINE="[#${PR_NUM}](${REPO_URL}/pulls/${PR_NUM}) · ${SOURCE_LINE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
BODY=$(cat <<EOF
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### ${CATEGORY}
|
||||||
|
${CHANGE_TITLE}
|
||||||
|
|
||||||
|
**Source:** ${SOURCE_LINE}
|
||||||
|
**Image:** \`${IMAGE}:${VERSION}\`
|
||||||
|
|
||||||
|
**Commits:**
|
||||||
|
${COMMIT_LIST}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
JSON_PAYLOAD=$(jq -n \
|
||||||
|
--arg tag "$VERSION" \
|
||||||
|
--arg name "$VERSION" \
|
||||||
|
--arg body "$BODY" \
|
||||||
|
'{tag_name: $tag, name: $name, target_commitish: "main", body: $body}')
|
||||||
|
|
||||||
|
curl -s -o /dev/null -w "Release API response: %{http_code}\n" -X POST \
|
||||||
|
-H "Authorization: token ${{ secrets.CI_PUSH_TOKEN }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$JSON_PAYLOAD" \
|
||||||
|
"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
|
||||||
@@ -2,5 +2,9 @@
|
|||||||
!.env
|
!.env
|
||||||
!.sh
|
!.sh
|
||||||
!.gitignore
|
!.gitignore
|
||||||
|
!.gitea
|
||||||
|
!.gitea/**
|
||||||
output/
|
output/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
htmlcov/
|
||||||
|
site_output.zip
|
||||||
+18
-2
@@ -1,4 +1,4 @@
|
|||||||
FROM python:3.13-alpine AS builder
|
FROM python:3.14.7-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ RUN apk add --no-cache gcc musl-dev jpeg-dev zlib-dev
|
|||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip wheel --no-cache-dir --wheel-dir=/wheels -r requirements.txt
|
RUN pip wheel --no-cache-dir --wheel-dir=/wheels -r requirements.txt
|
||||||
|
|
||||||
FROM python:3.13-alpine
|
FROM python:3.14.7-alpine AS base
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -20,4 +20,20 @@ COPY ./config /app/default
|
|||||||
COPY ./docker/.sh/entrypoint.sh /app/entrypoint.sh
|
COPY ./docker/.sh/entrypoint.sh /app/entrypoint.sh
|
||||||
RUN chmod +x /app/entrypoint.sh
|
RUN chmod +x /app/entrypoint.sh
|
||||||
|
|
||||||
|
FROM base AS test
|
||||||
|
|
||||||
|
COPY requirements.txt requirements-dev.txt pytest.ini /app/
|
||||||
|
RUN pip install --no-cache-dir -r requirements-dev.txt
|
||||||
|
COPY ./tests/ ./tests/
|
||||||
|
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
|
||||||
|
|
||||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
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__":
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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__":
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
pythonpath = .
|
||||||
|
testpaths = tests
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": ["config:recommended"],
|
||||||
|
"timezone": "Europe/Paris",
|
||||||
|
"labels": ["bot"],
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"matchManagers": ["pip_requirements"],
|
||||||
|
"matchUpdateTypes": ["patch", "minor"],
|
||||||
|
"automerge": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchManagers": ["dockerfile"],
|
||||||
|
"matchUpdateTypes": ["patch"],
|
||||||
|
"automerge": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchUpdateTypes": ["major"],
|
||||||
|
"addLabels": ["major"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchUpdateTypes": ["minor"],
|
||||||
|
"addLabels": ["minor"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"vulnerabilityAlerts": {
|
||||||
|
"addLabels": ["bug"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest==9.1.1
|
||||||
|
pytest-cov==7.1.0
|
||||||
@@ -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"]
|
||||||
@@ -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">'
|
||||||
|
|||||||
@@ -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("\\", "/")
|
str(p.relative_to(directory.parent)).replace("\\", "/")
|
||||||
for p in directory.rglob("*")
|
for p in directory.rglob("*")
|
||||||
if p.suffix.lower() in [".jpg", ".jpeg", ".png", ".webp"]
|
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)")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -128,7 +146,9 @@ def build():
|
|||||||
# 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
@@ -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]:
|
||||||
|
|||||||
@@ -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):
|
||||||
"""
|
"""
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+110
-40
@@ -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,6 +574,7 @@ 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")
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
DEMO_ROOT = REPO_ROOT / "demo" / "config"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def repo_root():
|
||||||
|
return REPO_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def demo_root():
|
||||||
|
return DEMO_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def make_image():
|
||||||
|
"""Factory that writes a small real image to disk and returns its path."""
|
||||||
|
|
||||||
|
def _make(path: Path, size=(64, 48), color=(200, 100, 50), fmt="JPEG", **save_kwargs):
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
Image.new("RGB", size, color).save(path, fmt, **save_kwargs)
|
||||||
|
return path
|
||||||
|
|
||||||
|
return _make
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app_env(tmp_path, monkeypatch, make_image):
|
||||||
|
"""
|
||||||
|
Isolate the Flask webui from the real repo filesystem.
|
||||||
|
|
||||||
|
webui.py resolves most config paths from `Path(__file__).resolve().parents[3]`
|
||||||
|
(recomputed inline in most routes) or from module-level globals computed once
|
||||||
|
at import time (PHOTOS_DIR, SITE_YAML). We patch `__file__` so inline lookups
|
||||||
|
land in a throwaway tmp_path repo skeleton, patch the two import-time globals
|
||||||
|
directly, and chdir so the plain-relative paths used by gallery_builder
|
||||||
|
(GALLERY_YAML, GALLERY_DIR, HERO_DIR) resolve there too.
|
||||||
|
"""
|
||||||
|
import src.py.webui.webui as webui
|
||||||
|
|
||||||
|
root = tmp_path
|
||||||
|
(root / "config" / "photos" / "gallery").mkdir(parents=True)
|
||||||
|
(root / "config" / "photos" / "hero").mkdir(parents=True)
|
||||||
|
|
||||||
|
theme_dir = root / "config" / "themes" / "modern"
|
||||||
|
theme_dir.mkdir(parents=True)
|
||||||
|
(theme_dir / "theme.yaml").write_text(
|
||||||
|
"colors:\n browser_color: '#ffffff'\nfavicon:\n path: favicon.png\ngoogle_fonts: []\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
make_image(theme_dir / "favicon.png", size=(32, 32), fmt="PNG")
|
||||||
|
|
||||||
|
(root / "config" / "gallery.yaml").write_text(
|
||||||
|
"hero:\n images: []\ngallery:\n images: []\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
(root / "config" / "site.yaml").write_text(
|
||||||
|
"info:\n title: Test\n canonical: https://example.com\nsocial:\n thumbnail: ''\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
fake_module_file = root / "src" / "py" / "webui" / "webui.py"
|
||||||
|
fake_module_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
monkeypatch.chdir(root)
|
||||||
|
monkeypatch.setattr(webui, "__file__", str(fake_module_file))
|
||||||
|
monkeypatch.setattr(webui, "PHOTOS_DIR", root / "config" / "photos")
|
||||||
|
monkeypatch.setattr(webui, "SITE_YAML", root / "config" / "site.yaml")
|
||||||
|
webui.app.config["PHOTOS_DIR"] = root / "config" / "photos"
|
||||||
|
webui.app.config["TESTING"] = True
|
||||||
|
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(app_env):
|
||||||
|
import src.py.webui.webui as webui
|
||||||
|
|
||||||
|
return webui.app.test_client()
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
from src.py.builder import utils
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_yaml_missing_file_returns_empty_dict(tmp_path, caplog):
|
||||||
|
result = utils.load_yaml(tmp_path / "missing.yaml")
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_yaml_parses_existing_file(tmp_path):
|
||||||
|
path = tmp_path / "data.yaml"
|
||||||
|
path.write_text("foo: bar\nnum: 1\n", encoding="utf-8")
|
||||||
|
assert utils.load_yaml(path) == {"foo": "bar", "num": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_theme_config_success(tmp_path):
|
||||||
|
themes_dir = tmp_path / "themes"
|
||||||
|
theme_dir = themes_dir / "modern"
|
||||||
|
theme_dir.mkdir(parents=True)
|
||||||
|
(theme_dir / "theme.yaml").write_text("colors:\n primary: '#000'\n", encoding="utf-8")
|
||||||
|
|
||||||
|
theme_vars, returned_dir = utils.load_theme_config("modern", themes_dir)
|
||||||
|
|
||||||
|
assert theme_vars == {"colors": {"primary": "#000"}}
|
||||||
|
assert returned_dir == theme_dir
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_theme_config_missing_raises(tmp_path):
|
||||||
|
themes_dir = tmp_path / "themes"
|
||||||
|
try:
|
||||||
|
utils.load_theme_config("missing", themes_dir)
|
||||||
|
raise AssertionError("expected FileNotFoundError")
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_dir_creates_missing_dir(tmp_path):
|
||||||
|
target = tmp_path / "out"
|
||||||
|
utils.clear_dir(target)
|
||||||
|
assert target.is_dir()
|
||||||
|
assert list(target.iterdir()) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_dir_removes_existing_content(tmp_path):
|
||||||
|
target = tmp_path / "out"
|
||||||
|
target.mkdir()
|
||||||
|
(target / "file.txt").write_text("x", encoding="utf-8")
|
||||||
|
sub = target / "sub"
|
||||||
|
sub.mkdir()
|
||||||
|
(sub / "nested.txt").write_text("y", encoding="utf-8")
|
||||||
|
|
||||||
|
utils.clear_dir(target)
|
||||||
|
|
||||||
|
assert target.is_dir()
|
||||||
|
assert list(target.iterdir()) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_dir_creates_and_clears(tmp_path):
|
||||||
|
target = tmp_path / "out"
|
||||||
|
utils.ensure_dir(target)
|
||||||
|
assert target.is_dir()
|
||||||
|
|
||||||
|
(target / "stale.txt").write_text("x", encoding="utf-8")
|
||||||
|
utils.ensure_dir(target)
|
||||||
|
assert list(target.iterdir()) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_copy_assets_copies_existing_folders(tmp_path):
|
||||||
|
js_dir = tmp_path / "js"
|
||||||
|
style_dir = tmp_path / "style"
|
||||||
|
build_dir = tmp_path / "output"
|
||||||
|
js_dir.mkdir()
|
||||||
|
style_dir.mkdir()
|
||||||
|
(js_dir / "app.js").write_text("console.log(1)", encoding="utf-8")
|
||||||
|
(style_dir / "style.css").write_text("body{}", encoding="utf-8")
|
||||||
|
|
||||||
|
utils.copy_assets(js_dir, style_dir, build_dir)
|
||||||
|
|
||||||
|
assert (build_dir / "js" / "app.js").exists()
|
||||||
|
assert (build_dir / "style" / "style.css").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_copy_assets_skips_missing_folder(tmp_path, caplog):
|
||||||
|
js_dir = tmp_path / "missing_js"
|
||||||
|
style_dir = tmp_path / "style"
|
||||||
|
style_dir.mkdir()
|
||||||
|
build_dir = tmp_path / "output"
|
||||||
|
|
||||||
|
utils.copy_assets(js_dir, style_dir, build_dir)
|
||||||
|
|
||||||
|
assert not (build_dir / "missing_js").exists()
|
||||||
|
assert (build_dir / "style").exists()
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
from src.py.builder import css_generator as cg
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_css_variables(tmp_path):
|
||||||
|
output_path = tmp_path / "style" / "colors.css"
|
||||||
|
cg.generate_css_variables({"primary_dark": "#005384", "accent": "#FFC700"}, output_path)
|
||||||
|
|
||||||
|
content = output_path.read_text(encoding="utf-8")
|
||||||
|
assert ":root {" in content
|
||||||
|
assert "--color-primary-dark: #005384;" in content
|
||||||
|
assert "--color-accent: #FFC700;" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_fonts_css_with_font_files_and_config(tmp_path):
|
||||||
|
fonts_dir = tmp_path / "theme" / "fonts"
|
||||||
|
fonts_dir.mkdir(parents=True)
|
||||||
|
(fonts_dir / "trixie.woff2").write_bytes(b"fake-woff2")
|
||||||
|
(fonts_dir / "trixie.woff").write_bytes(b"fake-woff")
|
||||||
|
(fonts_dir / "readme.txt").write_text("skip me", encoding="utf-8")
|
||||||
|
|
||||||
|
output_path = tmp_path / "build" / "style" / "fonts.css"
|
||||||
|
fonts_cfg = {
|
||||||
|
"primary": {"name": "Lato", "fallback": "sans-serif"},
|
||||||
|
"secondary": {"name": "Trixie", "fallback": "monospace"},
|
||||||
|
}
|
||||||
|
|
||||||
|
preload_links = cg.generate_fonts_css(fonts_dir, output_path, fonts_cfg=fonts_cfg)
|
||||||
|
|
||||||
|
content = output_path.read_text(encoding="utf-8")
|
||||||
|
assert "@font-face {" in content
|
||||||
|
assert "font-family: 'trixie';" in content
|
||||||
|
assert "--font-primary: 'Lato', sans-serif;" in content
|
||||||
|
assert "--font-secondary: 'Trixie', monospace;" in content
|
||||||
|
|
||||||
|
assert (tmp_path / "build" / "fonts" / "trixie.woff2").exists()
|
||||||
|
assert (tmp_path / "build" / "fonts" / "trixie.woff").exists()
|
||||||
|
assert len(preload_links) == 2
|
||||||
|
assert any("trixie.woff2" in link for link in preload_links)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_fonts_css_with_no_fonts(tmp_path):
|
||||||
|
fonts_dir = tmp_path / "empty_fonts"
|
||||||
|
fonts_dir.mkdir()
|
||||||
|
output_path = tmp_path / "build" / "fonts.css"
|
||||||
|
|
||||||
|
preload_links = cg.generate_fonts_css(fonts_dir, output_path)
|
||||||
|
|
||||||
|
assert preload_links == []
|
||||||
|
assert output_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_google_fonts_link_empty():
|
||||||
|
assert cg.generate_google_fonts_link([]) == ""
|
||||||
|
assert cg.generate_google_fonts_link(None) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_google_fonts_link_with_weights():
|
||||||
|
fonts = [
|
||||||
|
{"family": "Lato", "weights": ["200", "400", "700"]},
|
||||||
|
{"family": "Open Sans"},
|
||||||
|
]
|
||||||
|
|
||||||
|
link = cg.generate_google_fonts_link(fonts)
|
||||||
|
|
||||||
|
assert "family=Lato:wght@200;400;700" in link
|
||||||
|
assert "family=Open+Sans" in link
|
||||||
|
assert link.startswith('<link href="https://fonts.googleapis.com/css2?')
|
||||||
|
assert link.endswith('&display=swap" rel="stylesheet">')
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
from src.py.builder import gallery_builder as gb
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_repo(tmp_path, monkeypatch):
|
||||||
|
(tmp_path / "config" / "photos" / "gallery").mkdir(parents=True)
|
||||||
|
(tmp_path / "config" / "photos" / "hero").mkdir(parents=True)
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_yaml_missing_file(tmp_path, monkeypatch):
|
||||||
|
_setup_repo(tmp_path, monkeypatch)
|
||||||
|
assert gb.load_yaml("config/gallery.yaml") == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_yaml_and_save_yaml_roundtrip(tmp_path, monkeypatch):
|
||||||
|
_setup_repo(tmp_path, monkeypatch)
|
||||||
|
data = {"gallery": {"images": [{"src": "gallery/a.jpg", "tags": ["x"]}]}}
|
||||||
|
gb.save_yaml(data, "config/gallery.yaml")
|
||||||
|
|
||||||
|
loaded = gb.load_yaml("config/gallery.yaml")
|
||||||
|
assert loaded == data
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_all_image_paths_filters_and_sorts(tmp_path, monkeypatch, make_image):
|
||||||
|
_setup_repo(tmp_path, monkeypatch)
|
||||||
|
gallery_dir = tmp_path / "config" / "photos" / "gallery"
|
||||||
|
make_image(gallery_dir / "b.jpg")
|
||||||
|
make_image(gallery_dir / "a.png", fmt="PNG")
|
||||||
|
(gallery_dir / "notes.txt").write_text("ignore me", encoding="utf-8")
|
||||||
|
|
||||||
|
paths = gb.get_all_image_paths(gallery_dir)
|
||||||
|
|
||||||
|
assert paths == ["gallery/a.png", "gallery/b.jpg"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_gallery_adds_new_and_removes_deleted(tmp_path, monkeypatch, make_image, capsys):
|
||||||
|
_setup_repo(tmp_path, monkeypatch)
|
||||||
|
gallery_dir = tmp_path / "config" / "photos" / "gallery"
|
||||||
|
make_image(gallery_dir / "kept.jpg")
|
||||||
|
make_image(gallery_dir / "new.jpg")
|
||||||
|
|
||||||
|
gb.save_yaml(
|
||||||
|
{
|
||||||
|
"gallery": {
|
||||||
|
"images": [
|
||||||
|
{"src": "gallery/kept.jpg", "tags": ["portrait"]},
|
||||||
|
{"src": "gallery/gone.jpg", "tags": ["stale"]},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
gb.GALLERY_YAML,
|
||||||
|
)
|
||||||
|
|
||||||
|
gb.update_gallery()
|
||||||
|
|
||||||
|
result = gb.load_yaml(gb.GALLERY_YAML)
|
||||||
|
images = {img["src"]: img for img in result["gallery"]["images"]}
|
||||||
|
assert set(images) == {"gallery/kept.jpg", "gallery/new.jpg"}
|
||||||
|
assert images["gallery/kept.jpg"]["tags"] == ["portrait"]
|
||||||
|
assert images["gallery/new.jpg"]["tags"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_gallery_no_changes(tmp_path, monkeypatch, make_image, capsys):
|
||||||
|
_setup_repo(tmp_path, monkeypatch)
|
||||||
|
gallery_dir = tmp_path / "config" / "photos" / "gallery"
|
||||||
|
make_image(gallery_dir / "only.jpg")
|
||||||
|
gb.save_yaml(
|
||||||
|
{"gallery": {"images": [{"src": "gallery/only.jpg", "tags": []}]}},
|
||||||
|
gb.GALLERY_YAML,
|
||||||
|
)
|
||||||
|
|
||||||
|
gb.update_gallery()
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "No changes to gallery.yaml (gallery)" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_hero_adds_new_and_removes_deleted(tmp_path, monkeypatch, make_image):
|
||||||
|
_setup_repo(tmp_path, monkeypatch)
|
||||||
|
hero_dir = tmp_path / "config" / "photos" / "hero"
|
||||||
|
make_image(hero_dir / "kept.jpg")
|
||||||
|
make_image(hero_dir / "new.jpg")
|
||||||
|
|
||||||
|
gb.save_yaml(
|
||||||
|
{
|
||||||
|
"hero": {
|
||||||
|
"images": [
|
||||||
|
{"src": "hero/kept.jpg"},
|
||||||
|
{"src": "hero/gone.jpg"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
gb.GALLERY_YAML,
|
||||||
|
)
|
||||||
|
|
||||||
|
gb.update_hero()
|
||||||
|
|
||||||
|
result = gb.load_yaml(gb.GALLERY_YAML)
|
||||||
|
srcs = {img["src"] for img in result["hero"]["images"]}
|
||||||
|
assert srcs == {"hero/kept.jpg", "hero/new.jpg"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_gallery_on_empty_yaml_initializes_section(tmp_path, monkeypatch, make_image):
|
||||||
|
_setup_repo(tmp_path, monkeypatch)
|
||||||
|
gallery_dir = tmp_path / "config" / "photos" / "gallery"
|
||||||
|
make_image(gallery_dir / "only.jpg")
|
||||||
|
|
||||||
|
gb.update_gallery()
|
||||||
|
|
||||||
|
result = gb.load_yaml(gb.GALLERY_YAML)
|
||||||
|
assert result["gallery"]["images"][0]["src"] == "gallery/only.jpg"
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from src.py.builder import html_generator as hg
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_template_replaces_placeholders(tmp_path):
|
||||||
|
template = tmp_path / "t.html"
|
||||||
|
template.write_text("<h1>{{ title }}</h1><p>{{ missing }}</p>", encoding="utf-8")
|
||||||
|
|
||||||
|
result = hg.render_template(template, {"title": "Hello", "missing": None})
|
||||||
|
|
||||||
|
assert result == "<h1>Hello</h1><p></p>"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_template_leaves_unknown_placeholders(tmp_path):
|
||||||
|
template = tmp_path / "t.html"
|
||||||
|
template.write_text("<h1>{{ title }}</h1>", encoding="utf-8")
|
||||||
|
|
||||||
|
result = hg.render_template(template, {})
|
||||||
|
|
||||||
|
assert result == "<h1>{{ title }}</h1>"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_gallery_images_with_and_without_tags():
|
||||||
|
images = [
|
||||||
|
{"src": "gallery/a.jpg", "tags": ["nature", "sky"], "alt": "A photo"},
|
||||||
|
{"src": "gallery/b.jpg"},
|
||||||
|
]
|
||||||
|
|
||||||
|
html = hg.render_gallery_images(images)
|
||||||
|
|
||||||
|
assert 'data-tags="nature sky"' in html
|
||||||
|
assert '<span class="tag">#nature</span>' in html
|
||||||
|
assert '<span class="tag">#sky</span>' in html
|
||||||
|
assert 'data-src="/img/gallery/a.jpg"' in html
|
||||||
|
assert 'alt="A photo"' in html
|
||||||
|
assert 'data-tags=""' in html
|
||||||
|
assert 'data-src="/img/gallery/b.jpg"' in html
|
||||||
|
assert 'alt=""' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_gallery_json_from_images(tmp_path):
|
||||||
|
images = [{"src": "hero/a.jpg"}, {"src": "hero/b.jpg"}]
|
||||||
|
|
||||||
|
hg.generate_gallery_json_from_images(images, tmp_path)
|
||||||
|
|
||||||
|
output_path = tmp_path / "data" / "gallery.json"
|
||||||
|
assert json.loads(output_path.read_text(encoding="utf-8")) == ["hero/a.jpg", "hero/b.jpg"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_robots_txt(tmp_path):
|
||||||
|
hg.generate_robots_txt("https://example.com/", ["/", "legals"], tmp_path)
|
||||||
|
|
||||||
|
content = (tmp_path / "robots.txt").read_text(encoding="utf-8")
|
||||||
|
assert "Disallow: /" in content
|
||||||
|
assert "Allow: /" in content
|
||||||
|
assert "Allow: /legals" in content
|
||||||
|
assert "Sitemap: https://example.com/sitemap.xml" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_sitemap_xml(tmp_path):
|
||||||
|
hg.generate_sitemap_xml("https://example.com", ["/", "/legals/"], tmp_path)
|
||||||
|
|
||||||
|
content = (tmp_path / "sitemap.xml").read_text(encoding="utf-8")
|
||||||
|
assert "<loc>https://example.com/</loc>" in content
|
||||||
|
assert "<loc>https://example.com/legals/</loc>" in content
|
||||||
|
assert content.startswith('<?xml version="1.0" encoding="UTF-8"?>')
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from src.py.builder import image_processor as ip
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_and_resize_image_missing_input_logs_and_skips(tmp_path):
|
||||||
|
output_path = tmp_path / "out.webp"
|
||||||
|
ip.convert_and_resize_image(tmp_path / "missing.jpg", output_path)
|
||||||
|
assert not output_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_and_resize_image_resizes_down(tmp_path, make_image):
|
||||||
|
input_path = make_image(tmp_path / "in.jpg", size=(2000, 1000))
|
||||||
|
output_path = tmp_path / "out.webp"
|
||||||
|
|
||||||
|
ip.convert_and_resize_image(input_path, output_path, resize=True, max_width=1000)
|
||||||
|
|
||||||
|
actual_output = output_path if output_path.exists() else output_path.with_suffix(".jpg")
|
||||||
|
assert actual_output.exists()
|
||||||
|
with Image.open(actual_output) as img:
|
||||||
|
assert img.width == 1000
|
||||||
|
assert img.height == 500
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_and_resize_image_no_resize_keeps_dimensions(tmp_path, make_image):
|
||||||
|
input_path = make_image(tmp_path / "in.jpg", size=(500, 300))
|
||||||
|
output_path = tmp_path / "out.webp"
|
||||||
|
|
||||||
|
ip.convert_and_resize_image(input_path, output_path, resize=False)
|
||||||
|
|
||||||
|
actual_output = output_path if output_path.exists() else output_path.with_suffix(".jpg")
|
||||||
|
with Image.open(actual_output) as img:
|
||||||
|
assert img.size == (500, 300)
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_and_resize_image_preserves_icc_profile(tmp_path):
|
||||||
|
input_path = tmp_path / "in.jpg"
|
||||||
|
icc_bytes = b"fake-icc-profile-bytes"
|
||||||
|
Image.new("RGB", (100, 80), (10, 20, 30)).save(input_path, "JPEG", icc_profile=icc_bytes)
|
||||||
|
output_path = tmp_path / "out.webp"
|
||||||
|
|
||||||
|
ip.convert_and_resize_image(input_path, output_path, resize=False)
|
||||||
|
|
||||||
|
actual_output = output_path if output_path.exists() else output_path.with_suffix(".jpg")
|
||||||
|
with Image.open(actual_output) as img:
|
||||||
|
assert img.info.get("icc_profile") == icc_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_images_updates_src_to_processed_extension(tmp_path, make_image):
|
||||||
|
img_dir = tmp_path / "photos"
|
||||||
|
build_dir = tmp_path / "output"
|
||||||
|
make_image(img_dir / "gallery" / "a.jpg", size=(200, 200))
|
||||||
|
images = [{"src": "gallery/a.jpg", "tags": []}]
|
||||||
|
|
||||||
|
ip.process_images(images, resize_images=True, img_dir=img_dir, build_dir=build_dir)
|
||||||
|
|
||||||
|
assert images[0]["src"] in ("gallery/a.webp", "gallery/a.jpg")
|
||||||
|
produced = build_dir / "img" / images[0]["src"]
|
||||||
|
assert produced.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_copy_original_images_copies_existing_and_skips_missing(tmp_path, make_image, caplog):
|
||||||
|
img_dir = tmp_path / "photos"
|
||||||
|
build_dir = tmp_path / "output"
|
||||||
|
make_image(img_dir / "gallery" / "present.jpg")
|
||||||
|
images = [{"src": "gallery/present.jpg"}, {"src": "gallery/absent.jpg"}]
|
||||||
|
|
||||||
|
ip.copy_original_images(images, img_dir, build_dir)
|
||||||
|
|
||||||
|
assert (build_dir / "img" / "gallery" / "present.jpg").exists()
|
||||||
|
assert not (build_dir / "img" / "gallery" / "absent.jpg").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_favicon_path_missing_config_returns_none(tmp_path):
|
||||||
|
assert ip.get_favicon_path({}, tmp_path) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_favicon_path_relative_and_absolute(tmp_path, make_image):
|
||||||
|
theme_dir = tmp_path / "theme"
|
||||||
|
icon_path = make_image(theme_dir / "favicon.png", fmt="PNG")
|
||||||
|
|
||||||
|
result = ip.get_favicon_path({"favicon": {"path": "favicon.png"}}, theme_dir)
|
||||||
|
assert result == icon_path
|
||||||
|
|
||||||
|
absolute_result = ip.get_favicon_path({"favicon": {"path": str(icon_path)}}, theme_dir)
|
||||||
|
assert absolute_result == icon_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_favicon_path_file_not_found(tmp_path):
|
||||||
|
result = ip.get_favicon_path({"favicon": {"path": "missing.png"}}, tmp_path)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_favicons_from_logo_creates_all_sizes(tmp_path, make_image):
|
||||||
|
theme_dir = tmp_path / "theme"
|
||||||
|
make_image(theme_dir / "favicon.png", size=(256, 256), fmt="PNG")
|
||||||
|
output_dir = tmp_path / "output" / "favicon"
|
||||||
|
|
||||||
|
ip.generate_favicons_from_logo({"favicon": {"path": "favicon.png"}}, theme_dir, output_dir)
|
||||||
|
|
||||||
|
expected = [
|
||||||
|
"favicon-32.png",
|
||||||
|
"favicon-96.png",
|
||||||
|
"favicon-128.png",
|
||||||
|
"favicon-192.png",
|
||||||
|
"favicon-196.png",
|
||||||
|
"favicon-152.png",
|
||||||
|
"favicon-180.png",
|
||||||
|
]
|
||||||
|
for name in expected:
|
||||||
|
assert (output_dir / name).exists()
|
||||||
|
with Image.open(output_dir / "favicon-32.png") as img:
|
||||||
|
assert img.size == (32, 32)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_favicons_from_logo_no_favicon_skips(tmp_path):
|
||||||
|
output_dir = tmp_path / "output" / "favicon"
|
||||||
|
ip.generate_favicons_from_logo({}, tmp_path, output_dir)
|
||||||
|
assert not output_dir.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_favicon_ico_creates_file(tmp_path, make_image):
|
||||||
|
theme_dir = tmp_path / "theme"
|
||||||
|
make_image(theme_dir / "favicon.png", size=(64, 64), fmt="PNG")
|
||||||
|
output_path = tmp_path / "output" / "favicon.ico"
|
||||||
|
|
||||||
|
ip.generate_favicon_ico({"favicon": {"path": "favicon.png"}}, theme_dir, output_path)
|
||||||
|
|
||||||
|
assert output_path.exists()
|
||||||
|
with Image.open(output_path) as img:
|
||||||
|
assert img.format == "ICO"
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_favicon_ico_no_favicon_skips(tmp_path):
|
||||||
|
output_path = tmp_path / "output" / "favicon.ico"
|
||||||
|
ip.generate_favicon_ico({}, tmp_path, output_path)
|
||||||
|
assert not output_path.exists()
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import yaml
|
||||||
|
|
||||||
|
from src.py.builder import site_builder
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_end_to_end_with_demo_content(tmp_path, monkeypatch, demo_root):
|
||||||
|
output_dir = tmp_path / "output"
|
||||||
|
monkeypatch.setattr(site_builder, "BUILD_DIR", output_dir)
|
||||||
|
monkeypatch.setattr(site_builder, "GALLERY_FILE", demo_root / "gallery.yaml")
|
||||||
|
monkeypatch.setattr(site_builder, "SITE_FILE", demo_root / "site.yaml")
|
||||||
|
monkeypatch.setattr(site_builder, "IMG_DIR", demo_root / "photos")
|
||||||
|
monkeypatch.setattr(site_builder, "THEMES_DIR", demo_root / "themes")
|
||||||
|
|
||||||
|
site_builder.build()
|
||||||
|
|
||||||
|
index_html = (output_dir / "index.html").read_text(encoding="utf-8")
|
||||||
|
assert "<h1>Lumeex</h1>" in index_html
|
||||||
|
assert "https://lumeex.djeex.fr/" in index_html
|
||||||
|
assert '<img class="fade-in-img lazyload"' in index_html
|
||||||
|
|
||||||
|
assert (output_dir / "legals" / "index.html").exists()
|
||||||
|
assert (output_dir / "robots.txt").exists()
|
||||||
|
assert (output_dir / "sitemap.xml").exists()
|
||||||
|
assert (output_dir / "data" / "gallery.json").exists()
|
||||||
|
assert (output_dir / "style" / "colors.css").exists()
|
||||||
|
assert (output_dir / "style" / "fonts.css").exists()
|
||||||
|
assert (output_dir / "style" / "theme.css").exists()
|
||||||
|
assert (output_dir / "favicon.ico").exists()
|
||||||
|
assert (output_dir / "img" / "favicon" / "favicon-192.png").exists()
|
||||||
|
assert (output_dir / "img" / "social").is_dir()
|
||||||
|
assert any((output_dir / "img" / "social").iterdir())
|
||||||
|
|
||||||
|
# convert_images=true in the demo config: originals become webp (or jpg fallback)
|
||||||
|
processed = list((output_dir / "img" / "gallery").glob("*"))
|
||||||
|
assert processed
|
||||||
|
assert all(p.suffix in (".webp", ".jpg") for p in processed)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_without_image_conversion_copies_originals(tmp_path, monkeypatch, demo_root):
|
||||||
|
site_data = yaml.safe_load((demo_root / "site.yaml").read_text(encoding="utf-8"))
|
||||||
|
site_data["build"]["convert_images"] = False
|
||||||
|
site_data["build"]["resize_images"] = False
|
||||||
|
site_file = tmp_path / "site.yaml"
|
||||||
|
site_file.write_text(yaml.dump(site_data), encoding="utf-8")
|
||||||
|
|
||||||
|
output_dir = tmp_path / "output"
|
||||||
|
monkeypatch.setattr(site_builder, "BUILD_DIR", output_dir)
|
||||||
|
monkeypatch.setattr(site_builder, "GALLERY_FILE", demo_root / "gallery.yaml")
|
||||||
|
monkeypatch.setattr(site_builder, "SITE_FILE", site_file)
|
||||||
|
monkeypatch.setattr(site_builder, "IMG_DIR", demo_root / "photos")
|
||||||
|
monkeypatch.setattr(site_builder, "THEMES_DIR", demo_root / "themes")
|
||||||
|
|
||||||
|
site_builder.build()
|
||||||
|
|
||||||
|
copied = list((output_dir / "img" / "gallery").glob("*.jpg"))
|
||||||
|
assert copied, "original jpg files should be copied unmodified"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_minimal_site_skips_optional_sections(tmp_path, monkeypatch, demo_root):
|
||||||
|
gallery_file = tmp_path / "gallery.yaml"
|
||||||
|
gallery_file.write_text("hero:\n images: []\ngallery:\n images: []\n", encoding="utf-8")
|
||||||
|
|
||||||
|
site_file = tmp_path / "site.yaml"
|
||||||
|
site_file.write_text(
|
||||||
|
"info:\n title: Minimal\n"
|
||||||
|
"menu:\n items: []\n"
|
||||||
|
"build:\n theme: modern\n convert_images: true\n resize_images: true\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
output_dir = tmp_path / "output"
|
||||||
|
monkeypatch.setattr(site_builder, "BUILD_DIR", output_dir)
|
||||||
|
monkeypatch.setattr(site_builder, "GALLERY_FILE", gallery_file)
|
||||||
|
monkeypatch.setattr(site_builder, "SITE_FILE", site_file)
|
||||||
|
monkeypatch.setattr(site_builder, "IMG_DIR", demo_root / "photos")
|
||||||
|
monkeypatch.setattr(site_builder, "THEMES_DIR", demo_root / "themes")
|
||||||
|
|
||||||
|
site_builder.build()
|
||||||
|
|
||||||
|
assert (output_dir / "index.html").exists()
|
||||||
|
assert not (output_dir / "legals").exists()
|
||||||
|
assert not (output_dir / "robots.txt").exists()
|
||||||
|
assert not (output_dir / "sitemap.xml").exists()
|
||||||
|
assert not (output_dir / "data" / "gallery.json").exists()
|
||||||
|
assert not (output_dir / "img" / "social").exists()
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import io
|
||||||
|
|
||||||
|
from src.py.webui.upload import allowed_file, save_uploaded_file
|
||||||
|
|
||||||
|
|
||||||
|
def test_allowed_file_accepts_known_extensions():
|
||||||
|
for name in ["a.jpg", "a.JPEG", "a.png", "a.webp"]:
|
||||||
|
assert allowed_file(name) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_allowed_file_rejects_unknown_or_missing_extension():
|
||||||
|
assert allowed_file("a.gif") is False
|
||||||
|
assert allowed_file("noextension") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_uploaded_file_sanitizes_filename(tmp_path):
|
||||||
|
class FakeFile:
|
||||||
|
filename = "../../evil.jpg"
|
||||||
|
|
||||||
|
def save(self, path):
|
||||||
|
self.saved_path = path
|
||||||
|
|
||||||
|
folder = tmp_path / "gallery"
|
||||||
|
fake_file = FakeFile()
|
||||||
|
|
||||||
|
result = save_uploaded_file(fake_file, folder)
|
||||||
|
|
||||||
|
assert result == "evil.jpg"
|
||||||
|
assert fake_file.saved_path == folder / "evil.jpg"
|
||||||
|
assert folder.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_photo_invalid_section(client):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/invalid/upload",
|
||||||
|
data={"files": (io.BytesIO(b"data"), "a.jpg")},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_photo_no_files_key(client):
|
||||||
|
resp = client.post("/api/gallery/upload", data={}, content_type="multipart/form-data")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_photo_skips_disallowed_extensions(client):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/gallery/upload",
|
||||||
|
data={"files": (io.BytesIO(b"data"), "notes.txt")},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "No valid files uploaded" in resp.get_json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_photo_saves_valid_files_and_updates_gallery(client, app_env):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/gallery/upload",
|
||||||
|
data={"files": (io.BytesIO(b"fake-jpg-bytes"), "photo.jpg")},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["status"] == "ok"
|
||||||
|
assert body["uploaded"] == ["photo.jpg"]
|
||||||
|
assert (app_env / "config" / "photos" / "gallery" / "photo.jpg").exists()
|
||||||
|
|
||||||
|
gallery = client.get("/api/gallery").get_json()
|
||||||
|
assert gallery[0]["src"] == "gallery/photo.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_photo_hero_section(client, app_env):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/hero/upload",
|
||||||
|
data={"files": (io.BytesIO(b"fake-jpg-bytes"), "photo.jpg")},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
assert resp.get_json()["status"] == "ok"
|
||||||
|
assert (app_env / "config" / "photos" / "hero" / "photo.jpg").exists()
|
||||||
|
|
||||||
|
hero = client.get("/api/hero").get_json()
|
||||||
|
assert hero[0]["src"] == "hero/photo.jpg"
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
import io
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_page(client):
|
||||||
|
resp = client.get("/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_gallery_and_hero_start_empty(client):
|
||||||
|
assert client.get("/api/gallery").get_json() == []
|
||||||
|
assert client.get("/api/hero").get_json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_gallery_and_hero(client, app_env):
|
||||||
|
images = [{"src": "gallery/a.jpg", "tags": ["x"]}]
|
||||||
|
resp = client.post("/api/gallery/update", json=images)
|
||||||
|
assert resp.get_json() == {"status": "ok"}
|
||||||
|
assert client.get("/api/gallery").get_json() == images
|
||||||
|
|
||||||
|
hero_images = [{"src": "hero/a.jpg"}]
|
||||||
|
resp = client.post("/api/hero/update", json=hero_images)
|
||||||
|
assert resp.get_json() == {"status": "ok"}
|
||||||
|
assert client.get("/api/hero").get_json() == hero_images
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_gallery_and_hero_pick_up_new_files(client, app_env, make_image):
|
||||||
|
make_image(app_env / "config" / "photos" / "gallery" / "new.jpg")
|
||||||
|
make_image(app_env / "config" / "photos" / "hero" / "new.jpg")
|
||||||
|
|
||||||
|
client.post("/api/gallery/refresh")
|
||||||
|
client.post("/api/hero/refresh")
|
||||||
|
|
||||||
|
gallery = client.get("/api/gallery").get_json()
|
||||||
|
hero = client.get("/api/hero").get_json()
|
||||||
|
assert gallery[0]["src"] == "gallery/new.jpg"
|
||||||
|
assert hero[0]["src"] == "hero/new.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_gallery_photo(client, app_env, make_image):
|
||||||
|
make_image(app_env / "config" / "photos" / "gallery" / "a.jpg")
|
||||||
|
|
||||||
|
resp = client.post("/api/gallery/delete", json={"src": "a.jpg"})
|
||||||
|
assert resp.get_json() == {"status": "ok"}
|
||||||
|
assert not (app_env / "config" / "photos" / "gallery" / "a.jpg").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_gallery_photo_not_found(client):
|
||||||
|
resp = client.post("/api/gallery/delete", json={"src": "missing.jpg"})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_hero_photo(client, app_env, make_image):
|
||||||
|
make_image(app_env / "config" / "photos" / "hero" / "a.jpg")
|
||||||
|
|
||||||
|
resp = client.post("/api/hero/delete", json={"src": "a.jpg"})
|
||||||
|
assert resp.get_json() == {"status": "ok"}
|
||||||
|
assert not (app_env / "config" / "photos" / "hero" / "a.jpg").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_all_gallery_photos(client, app_env, make_image):
|
||||||
|
make_image(app_env / "config" / "photos" / "gallery" / "a.jpg")
|
||||||
|
make_image(app_env / "config" / "photos" / "gallery" / "b.jpg")
|
||||||
|
client.post("/api/gallery/update", json=[{"src": "gallery/a.jpg"}, {"src": "gallery/b.jpg"}])
|
||||||
|
|
||||||
|
resp = client.post("/api/gallery/delete_all")
|
||||||
|
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["status"] == "ok"
|
||||||
|
assert body["deleted"] == 2
|
||||||
|
assert client.get("/api/gallery").get_json() == []
|
||||||
|
assert list((app_env / "config" / "photos" / "gallery").iterdir()) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_all_hero_photos(client, app_env, make_image):
|
||||||
|
make_image(app_env / "config" / "photos" / "hero" / "a.jpg")
|
||||||
|
client.post("/api/hero/update", json=[{"src": "hero/a.jpg"}])
|
||||||
|
|
||||||
|
resp = client.post("/api/hero/delete_all")
|
||||||
|
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["deleted"] == 1
|
||||||
|
assert client.get("/api/hero").get_json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_serve_photo_by_section(client, app_env, make_image):
|
||||||
|
make_image(app_env / "config" / "photos" / "gallery" / "a.jpg")
|
||||||
|
resp = client.get("/photos/gallery/a.jpg")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_serve_photo_at_root(client, app_env, make_image):
|
||||||
|
make_image(app_env / "config" / "photos" / "thumbnail.png", fmt="PNG")
|
||||||
|
resp = client.get("/photos/thumbnail.png")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_site_info_get_and_post(client, app_env):
|
||||||
|
resp = client.get("/api/site-info")
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["info"]["title"] == "Test"
|
||||||
|
|
||||||
|
client.post(
|
||||||
|
"/api/site-info",
|
||||||
|
json={
|
||||||
|
"info": {"subtitle": "New subtitle"},
|
||||||
|
"social": {"instagram_url": "https://insta.example"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = client.get("/api/site-info").get_json()
|
||||||
|
assert updated["info"]["title"] == "Test"
|
||||||
|
assert updated["info"]["subtitle"] == "New subtitle"
|
||||||
|
assert updated["social"]["instagram_url"] == "https://insta.example"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_themes(client):
|
||||||
|
assert client.get("/api/themes").get_json() == ["modern"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_thumbnail_upload_and_remove(client, app_env):
|
||||||
|
data = {"file": (io.BytesIO(b"fake-png-bytes"), "thumb.png")}
|
||||||
|
resp = client.post("/api/thumbnail/upload", data=data, content_type="multipart/form-data")
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["status"] == "ok"
|
||||||
|
assert (app_env / "config" / "photos" / "thumbnail.png").exists()
|
||||||
|
|
||||||
|
site_info = client.get("/api/site-info").get_json()
|
||||||
|
assert site_info["social"]["thumbnail"] == "thumbnail.png"
|
||||||
|
|
||||||
|
resp = client.post("/api/thumbnail/remove")
|
||||||
|
assert resp.get_json() == {"status": "ok"}
|
||||||
|
assert not (app_env / "config" / "photos" / "thumbnail.png").exists()
|
||||||
|
site_info = client.get("/api/site-info").get_json()
|
||||||
|
assert site_info["social"]["thumbnail"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_thumbnail_upload_missing_file(client):
|
||||||
|
resp = client.post("/api/thumbnail/upload", data={}, content_type="multipart/form-data")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_upload_and_remove(client, app_env):
|
||||||
|
data = {
|
||||||
|
"files": [
|
||||||
|
(io.BytesIO(b"colors: {}"), "custom/theme.yaml"),
|
||||||
|
(io.BytesIO(b"body{}"), "custom/theme.css"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
resp = client.post("/api/theme/upload", data=data, content_type="multipart/form-data")
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["status"] == "ok"
|
||||||
|
assert body["theme"] == "custom"
|
||||||
|
assert (app_env / "config" / "themes" / "custom" / "theme.yaml").exists()
|
||||||
|
assert (app_env / "config" / "themes" / "custom" / "theme.css").exists()
|
||||||
|
|
||||||
|
resp = client.post("/api/theme/remove", json={"theme": "custom"})
|
||||||
|
assert resp.get_json() == {"status": "ok"}
|
||||||
|
assert not (app_env / "config" / "themes" / "custom").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_remove_protected_theme(client):
|
||||||
|
resp = client.post("/api/theme/remove", json={"theme": "modern"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_remove_missing_theme(client):
|
||||||
|
resp = client.post("/api/theme/remove", json={"theme": "does-not-exist"})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_info_get_and_post(client, app_env):
|
||||||
|
resp = client.get("/api/theme-info")
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["theme_name"] == "modern"
|
||||||
|
assert body["theme_yaml"]["favicon"]["path"] == "favicon.png"
|
||||||
|
|
||||||
|
new_theme_yaml = dict(body["theme_yaml"])
|
||||||
|
new_theme_yaml["colors"] = {"primary": "#123456"}
|
||||||
|
resp = client.post(
|
||||||
|
"/api/theme-info",
|
||||||
|
json={"theme_name": "modern", "theme_yaml": new_theme_yaml},
|
||||||
|
)
|
||||||
|
assert resp.get_json() == {"status": "ok"}
|
||||||
|
|
||||||
|
saved = yaml.safe_load((app_env / "config" / "themes" / "modern" / "theme.yaml").read_text())
|
||||||
|
assert saved["colors"]["primary"] == "#123456"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_theme_google_fonts(client, app_env):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/theme-google-fonts",
|
||||||
|
json={"theme_name": "modern", "google_fonts": [{"family": "Lato"}]},
|
||||||
|
)
|
||||||
|
assert resp.get_json() == {"status": "ok"}
|
||||||
|
|
||||||
|
saved = yaml.safe_load((app_env / "config" / "themes" / "modern" / "theme.yaml").read_text())
|
||||||
|
assert saved["google_fonts"] == [{"family": "Lato"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_fonts_empty_then_populated(client, app_env):
|
||||||
|
assert client.get("/api/local-fonts?theme=modern").get_json() == []
|
||||||
|
|
||||||
|
fonts_dir = app_env / "config" / "themes" / "modern" / "fonts"
|
||||||
|
fonts_dir.mkdir(parents=True)
|
||||||
|
(fonts_dir / "trixie.woff2").write_bytes(b"fake")
|
||||||
|
|
||||||
|
assert client.get("/api/local-fonts?theme=modern").get_json() == ["trixie.woff2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_favicon_upload_invalid_extension(client):
|
||||||
|
data = {"theme": "modern", "file": (io.BytesIO(b"not-an-image"), "favicon.txt")}
|
||||||
|
resp = client.post("/api/favicon/upload", data=data, content_type="multipart/form-data")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_favicon_upload_and_remove(client, app_env):
|
||||||
|
data = {"theme": "modern", "file": (io.BytesIO(b"fake-png"), "favicon.png")}
|
||||||
|
resp = client.post("/api/favicon/upload", data=data, content_type="multipart/form-data")
|
||||||
|
assert resp.get_json()["status"] == "ok"
|
||||||
|
assert (app_env / "config" / "themes" / "modern" / "favicon.png").exists()
|
||||||
|
|
||||||
|
resp = client.post("/api/favicon/remove", json={"theme": "modern"})
|
||||||
|
assert resp.get_json() == {"status": "ok"}
|
||||||
|
assert not (app_env / "config" / "themes" / "modern" / "favicon.png").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_serve_theme_asset(client, app_env):
|
||||||
|
(app_env / "config" / "themes" / "modern" / "theme.css").write_text("body{}", encoding="utf-8")
|
||||||
|
resp = client.get("/themes/modern/theme.css")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_font_upload_invalid_extension(client):
|
||||||
|
data = {"theme": "modern", "file": (io.BytesIO(b"data"), "font.ttf")}
|
||||||
|
resp = client.post("/api/font/upload", data=data, content_type="multipart/form-data")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_font_upload_and_remove(client, app_env):
|
||||||
|
data = {"theme": "modern", "file": (io.BytesIO(b"data"), "trixie.woff2")}
|
||||||
|
resp = client.post("/api/font/upload", data=data, content_type="multipart/form-data")
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["status"] == "ok"
|
||||||
|
assert body["filename"] == "trixie"
|
||||||
|
|
||||||
|
resp = client.post("/api/font/remove", json={"theme": "modern", "font": "trixie.woff2"})
|
||||||
|
assert resp.get_json() == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_font_remove_missing(client):
|
||||||
|
resp = client.post("/api/font/remove", json={"theme": "modern", "font": "missing.woff2"})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_trigger_build_missing_site_yaml(client, app_env):
|
||||||
|
(app_env / "config" / "site.yaml").unlink()
|
||||||
|
resp = client.post("/api/build")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "site.yaml not found" in resp.get_json()["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_trigger_build_missing_required_field(client, app_env):
|
||||||
|
resp = client.post("/api/build")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "Site info are not set" in resp.get_json()["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_trigger_build_success_invokes_subprocess(client, app_env, monkeypatch):
|
||||||
|
import src.py.webui.webui as webui
|
||||||
|
|
||||||
|
(app_env / "config" / "site.yaml").write_text(
|
||||||
|
"info:\n title: Test\n canonical: https://example.com\nsocial:\n thumbnail: t.png\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(webui.subprocess, "run", lambda *a, **k: calls.append((a, k)))
|
||||||
|
|
||||||
|
resp = client.post("/api/build")
|
||||||
|
assert resp.get_json() == {"status": "ok"}
|
||||||
|
assert calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_trigger_build_subprocess_failure_returns_500(client, app_env, monkeypatch):
|
||||||
|
import src.py.webui.webui as webui
|
||||||
|
|
||||||
|
(app_env / "config" / "site.yaml").write_text(
|
||||||
|
"info:\n title: Test\n canonical: https://example.com\nsocial:\n thumbnail: t.png\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _raise(*a, **k):
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
monkeypatch.setattr(webui.subprocess, "run", _raise)
|
||||||
|
|
||||||
|
resp = client.post("/api/build")
|
||||||
|
assert resp.status_code == 500
|
||||||
|
assert "boom" in resp.get_json()["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_output_zip(client, app_env):
|
||||||
|
output_dir = app_env / "output"
|
||||||
|
output_dir.mkdir()
|
||||||
|
(output_dir / "index.html").write_text("<html></html>", encoding="utf-8")
|
||||||
|
|
||||||
|
resp = client.post("/download-output-zip")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
zip_bytes = io.BytesIO(resp.data)
|
||||||
|
with zipfile.ZipFile(zip_bytes) as zf:
|
||||||
|
assert "index.html" in zf.namelist()
|
||||||
|
assert not (app_env / "site_output.zip").exists()
|
||||||
Reference in New Issue
Block a user