From f76420b2c33b69bb7aa1d5a2c88ce58a9769f508 Mon Sep 17 00:00:00 2001 From: Djeex Date: Tue, 12 Aug 2025 17:08:31 +0000 Subject: [PATCH] favicon log + better robot.txt + modular starter --- build.py | 187 +------------------------------------- src/py/builder.py | 184 +++++++++++++++++++++++++++++++++++++ src/py/html_generator.py | 28 ++++-- src/py/image_processor.py | 102 +++++++++++++++------ 4 files changed, 284 insertions(+), 217 deletions(-) create mode 100644 src/py/builder.py diff --git a/build.py b/build.py index 9950425..e176a3f 100644 --- a/build.py +++ b/build.py @@ -1,187 +1,6 @@ import logging -from datetime import datetime -from pathlib import Path -from shutil import copyfile -from PIL import Image -from src.py.utils import ensure_dir, copy_assets, load_yaml, load_theme_config -from src.py.css_generator import generate_css_variables, generate_fonts_css, generate_google_fonts_link -from src.py.image_processor import process_images, copy_original_images, convert_and_resize_image, generate_favicons_from_logo, generate_favicon_ico -from src.py.html_generator import render_template, render_gallery_images, generate_gallery_json_from_images, generate_robots_txt, generate_sitemap_xml - -# Configure logging to display only the messages -logging.basicConfig(level=logging.INFO, format='%(message)s') - -# Define key directories used throughout the script -SRC_DIR = Path.cwd() -BUILD_DIR = SRC_DIR / "output" -TEMPLATE_DIR = SRC_DIR / "src/templates" -IMG_DIR = SRC_DIR / "config/photos" -JS_DIR = SRC_DIR / "src/public/js" -STYLE_DIR = SRC_DIR / "src/public/style" -GALLERY_FILE = SRC_DIR / "config/gallery.yaml" -SITE_FILE = SRC_DIR / "config/site.yaml" -THEMES_DIR = SRC_DIR / "config/themes" - -def build(): - logging.info("🚀 Starting build...") - ensure_dir(BUILD_DIR) - copy_assets(JS_DIR, STYLE_DIR, BUILD_DIR) - - # Defining build vars - build_date = datetime.now().strftime("%Y%m%d%H%M%S") - build_date_version = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - site_vars = load_yaml(SITE_FILE) - gallery_vars = load_yaml(GALLERY_FILE) - build_section = site_vars.get("build", {}) - theme_name = site_vars.get("build", {}).get("theme", "default") - theme_vars, theme_dir = load_theme_config(theme_name, THEMES_DIR) - fonts_dir = theme_dir / "fonts" - theme_css_path = theme_dir / "theme.css" - canonical_url = site_vars.get("info", {}).get("canonical", "").rstrip("/") - canonical_home = f"{canonical_url}/" - canonical_legals = f"{canonical_url}/legals/" - - # Copying theme.css if existing - if theme_css_path.exists(): - dest_theme_css = BUILD_DIR / "style" / "theme.css" - dest_theme_css.parent.mkdir(parents=True, exist_ok=True) - copyfile(theme_css_path, dest_theme_css) - theme_css = f'' - logging.info(f"[✓] Theme CSS found, copied to build folder: {dest_theme_css}") - else: - theme_css = "" - logging.warning(f"[~] No theme.css found in {theme_css_path}, skipping theme CSS injection.") - - preload_links = generate_fonts_css(fonts_dir, BUILD_DIR / "style" / "fonts.css", fonts_cfg=theme_vars.get("fonts")) - generate_css_variables(theme_vars.get("colors", {}), BUILD_DIR / "style" / "colors.css") - generate_favicons_from_logo(theme_vars, theme_dir, BUILD_DIR / "img" / "favicon") - generate_favicon_ico(theme_vars, theme_dir, BUILD_DIR / "favicon.ico") - - # Converting and resizing images if enabled - convert_images = build_section.get("convert_images", True) - resize_images = build_section.get("resize_images", True) - logging.info(f"[~] convert_images = {convert_images}") - logging.info(f"[~] resize_images = {resize_images}") - - hero_images = gallery_vars.get("hero", {}).get("images", []) - gallery_images = gallery_vars.get("gallery", {}).get("images", []) - - if convert_images: - process_images(hero_images, resize_images, IMG_DIR, BUILD_DIR) - process_images(gallery_images, resize_images, IMG_DIR, BUILD_DIR) - else: - copy_original_images(hero_images, IMG_DIR, BUILD_DIR) - copy_original_images(gallery_images, IMG_DIR, BUILD_DIR) - - if "hero" not in site_vars: - site_vars["hero"] = {} # Initialize an empty hero section - - # Adding menu - menu_html = "\n".join( - f'' - for item in site_vars.get("menu", {}).get("items", []) - ) - site_vars["hero"]["menu_items"] = menu_html - if "footer" in site_vars: - site_vars["footer"]["menu_items"] = menu_html - - # Adding Google fonts if existing - google_fonts_link = generate_google_fonts_link(theme_vars.get("google_fonts", [])) - logging.info(f"[✓] Google Fonts link generated:\n{google_fonts_link}") - - # Generating thumbnail - thumbnail_path = site_vars.get("social", {}).get("thumbnail") - if thumbnail_path: - src_thumb = IMG_DIR / thumbnail_path - dest_thumb_dir = BUILD_DIR / "img" / "social" - dest_thumb_dir.mkdir(parents=True, exist_ok=True) - dest_thumb = dest_thumb_dir / Path(thumbnail_path).name - try: - img = Image.open(src_thumb) - img = img.convert("RGB") - img = img.resize((1200, 630), Image.LANCZOS) - img.save(dest_thumb, "JPEG", quality=90) - logging.info(f"[✓] Thumbnail resized and saved to {dest_thumb}") - except Exception as e: - logging.error(f"[✗] Failed to process thumbnail: {e}") - else: - logging.warning("[~] No thumbnail found in social section") - - # Defining head variables - head_vars = dict(site_vars.get("info", {})) - head_vars.update(theme_vars.get("colors", {})) - head_vars.update(site_vars.get("social", {})) - head_vars["thumbnail"] = f"/img/social/{Path(thumbnail_path).name}" if thumbnail_path else "" - head_vars["google_fonts_link"] = google_fonts_link - head_vars["font_preloads"] = "\n".join(preload_links) - head_vars["theme_css"] = theme_css - head_vars["build_date"] = build_date - head_vars["canonical"] = canonical_home - - # Render the home page - head = render_template(TEMPLATE_DIR / "head.html", head_vars) - hero = render_template(TEMPLATE_DIR / "hero.html", {**site_vars["hero"], **head_vars}) - footer = render_template(TEMPLATE_DIR / "footer.html", {**site_vars.get("footer", {}), **head_vars}) - gallery_html = render_gallery_images(gallery_images) - gallery = render_template(TEMPLATE_DIR / "gallery.html", {"gallery_images": gallery_html}) - - signature = f"" - body = f""" - -
- {hero} - {gallery} - {footer} - - """ - output_file = BUILD_DIR / "index.html" - with open(output_file, "w", encoding="utf-8") as f: - f.write(f"\n{signature}\n\n{head}\n{body}\n") - logging.info(f"[✓] HTML generated: {output_file}") - - # Rendering legals page - head_vars["canonical"] = canonical_legals - - legals_vars = site_vars.get("legals", {}) - if legals_vars: - head = render_template(TEMPLATE_DIR / "head.html", head_vars) - - ip_paragraphs = legals_vars.get("intellectual_property", []) - paragraphs_html = "\n".join(f"

{item['paragraph']}

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

{item['paragraph']}

" for item in ip_paragraphs) + legals_context = { + "hoster_name": legals_vars.get("hoster_name", ""), + "hoster_adress": legals_vars.get("hoster_adress", ""), + "hoster_contact": legals_vars.get("hoster_contact", ""), + "intellectual_property": paragraphs_html, + } + legals_body = render_template(TEMPLATE_DIR / "legals.html", legals_context) + legals_html = f"\n{signature}\n\n{head}\n{legals_body}\n{footer}\n" + output_legals = BUILD_DIR / "legals" / "index.html" + output_legals.parent.mkdir(parents=True, exist_ok=True) + with open(output_legals, "w", encoding="utf-8") as f: + f.write(legals_html) + logging.info(f"[✓] Legals page generated: {output_legals}") + else: + logging.warning("[~] No legals section found in site.yaml") + + # Hero carrousel generator + if hero_images: + generate_gallery_json_from_images(hero_images, BUILD_DIR) + else: + logging.warning("[~] No hero images found, skipping JSON generation.") + + # Sitemap and robot.txt generator + site_info = site_vars.get("info", {}) + canonical_url = site_info.get("canonical", "").rstrip("/") + if canonical_url: + allowed_pages = ["/", "/legals/"] + generate_robots_txt(canonical_url, allowed_pages, BUILD_DIR) + generate_sitemap_xml(canonical_url, allowed_pages, BUILD_DIR) + else: + logging.warning("[~] No canonical URL found in site.yaml info section, skipping robots.txt and sitemap.xml generation.") + + logging.info("✅ Build complete.") + \ No newline at end of file diff --git a/src/py/html_generator.py b/src/py/html_generator.py index 4eca005..2ac932e 100644 --- a/src/py/html_generator.py +++ b/src/py/html_generator.py @@ -36,16 +36,30 @@ def generate_gallery_json_from_images(images, output_dir): def generate_robots_txt(canonical_url, allowed_paths, output_dir): robots_lines = ["User-agent: *"] - for path in allowed_paths: - robots_lines.append(f"Allow: {path}") + + # Block everything by default robots_lines.append("Disallow: /") + + # Explicitly allow certain paths + for path in allowed_paths: + if not path.startswith("/"): + path = "/" + path + robots_lines.append(f"Allow: {path}") + robots_lines.append("") - robots_lines.append(f"Sitemap: {canonical_url}/sitemap.xml") + robots_lines.append(f"Sitemap: {canonical_url.rstrip('/')}/sitemap.xml") + content = "\n".join(robots_lines) - output_path = output_dir / "robots.txt" - with open(output_path, "w", encoding="utf-8") as f: - f.write(content) - logging.info(f"[✓] robots.txt generated at {output_path}") + output_path = Path(output_dir) / "robots.txt" + + try: + output_path.parent.mkdir(parents=True, exist_ok=True) + with open(output_path, "w", encoding="utf-8") as f: + f.write(content) + logging.info(f"[✓] robots.txt generated at {output_path}") + + except Exception as e: + logging.error(f"[✗] Failed to write robots.txt: {e}") def generate_sitemap_xml(canonical_url, allowed_paths, output_dir): urlset_start = '\n\n' diff --git a/src/py/image_processor.py b/src/py/image_processor.py index 12e5ade..a9cf0f6 100644 --- a/src/py/image_processor.py +++ b/src/py/image_processor.py @@ -1,73 +1,123 @@ import logging from pathlib import Path -from PIL import Image +from PIL import Image, features from shutil import copyfile def convert_and_resize_image(input_path, output_path, resize=True, max_width=1140): + """Convert an image to WebP (or JPEG fallback) and optionally resize it.""" try: + if not input_path.exists(): + logging.error(f"[✗] Image file not found: {input_path}") + return + img = Image.open(input_path) if img.mode != "RGB": img = img.convert("RGB") + if resize: width, height = img.size if width > max_width: new_height = int((max_width / width) * height) img = img.resize((max_width, new_height), Image.LANCZOS) + output_path.parent.mkdir(parents=True, exist_ok=True) - img.save(output_path, "WEBP", quality=100) - logging.info(f"[✓] Processed: {input_path} → {output_path}") + + # Check WebP support, otherwise fallback to JPEG + fmt = "WEBP" if features.check("webp") else "JPEG" + if fmt == "JPEG": + output_path = output_path.with_suffix(".jpg") + + img.save(output_path, fmt, quality=90 if fmt == "JPEG" else 100) + logging.info(f"[✓] Processed image: {input_path} → {output_path}") + except Exception as e: - logging.error(f"[✗] Failed to process {input_path}: {e}") + logging.error(f"[✗] Error processing image {input_path}: {e}") def process_images(images, resize_images, img_dir, build_dir): + """Process a list of image references and update paths to optimized versions.""" for img in images: src_path = img_dir / img["src"] webp_path = build_dir / "img" / Path(img["src"]).with_suffix(".webp") convert_and_resize_image(src_path, webp_path, resize=resize_images) - img["src"] = str(Path(img["src"]).with_suffix(".webp")) + + if webp_path.exists(): + img["src"] = str(Path(img["src"]).with_suffix(".webp")) + else: + # Fallback if WebP not created + jpg_path = webp_path.with_suffix(".jpg") + if jpg_path.exists(): + img["src"] = str(Path(img["src"]).with_suffix(".jpg")) def copy_original_images(images, img_dir, build_dir): + """Copy original image files without processing.""" for img in images: src_path = img_dir / img["src"] dest_path = build_dir / "img" / img["src"] + try: + if not src_path.exists(): + logging.error(f"[✗] Original image not found: {src_path}") + continue + dest_path.parent.mkdir(parents=True, exist_ok=True) copyfile(src_path, dest_path) logging.info(f"[✓] Copied original: {src_path} → {dest_path}") + except Exception as e: - logging.error(f"[✗] Failed to copy {src_path}: {e}") + logging.error(f"[✗] Error copying {src_path}: {e}") + +def get_favicon_path(theme_vars, theme_dir): + """Retrieve the favicon path from theme variables, ensuring it exists.""" + fav_path = theme_vars.get("favicon", {}).get("path") + if not fav_path: + logging.warning("[~] No favicon path defined in theme.yaml") + return None + + path = Path(fav_path) + if not path.is_absolute(): + path = theme_dir / path + + if not path.exists(): + logging.error(f"[✗] Favicon not found: {path}") + return None + + return path def generate_favicons_from_logo(theme_vars, theme_dir, output_dir): + """Generate multiple PNG favicons from a single source image.""" logo_path = get_favicon_path(theme_vars, theme_dir) if not logo_path: - logging.warning("[~] No favicon path defined, skipping favicon PNGs.") + logging.warning("[~] PNG favicons not generated.") return - output_dir.mkdir(parents=True, exist_ok=True) - specs = [(32, "favicon-32.png"), (96, "favicon-96.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") - for size, name in specs: - img.resize((size, size), Image.LANCZOS).save(output_dir / name, format="PNG") - logging.info(f"[✓] Favicons generated in {output_dir}") + + try: + output_dir.mkdir(parents=True, exist_ok=True) + specs = [ + (32, "favicon-32.png"), (96, "favicon-96.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") + for size, name in specs: + img.resize((size, size), Image.LANCZOS).save(output_dir / name, format="PNG") + + logging.info(f"[✓] PNG favicons generated in {output_dir}") + + except Exception as e: + logging.error(f"[✗] Error generating PNG favicons: {e}") def generate_favicon_ico(theme_vars, theme_dir, output_path): + """Generate a multi-size favicon.ico from a source image.""" logo_path = get_favicon_path(theme_vars, theme_dir) if not logo_path: - logging.warning("[~] No favicon path defined, skipping .ico generation.") + logging.warning("[~] favicon.ico not generated.") return + try: img = Image.open(logo_path).convert("RGBA") output_path.parent.mkdir(parents=True, exist_ok=True) img.save(output_path, format="ICO", sizes=[(16, 16), (32, 32), (48, 48)]) - logging.info(f"[✓] favicon.ico generated at {output_path}") - except Exception as e: - logging.error(f"[✗] Failed to generate favicon.ico: {e}") + logging.info(f"[✓] favicon.ico generated in {output_path}") -def get_favicon_path(theme_vars, theme_dir): - fav_path = theme_vars.get("favicon", {}).get("path") - if not fav_path: - return None - path = Path(fav_path) - if not path.is_absolute(): - path = theme_dir / path - return path if path.exists() else None + except Exception as e: + logging.error(f"[✗] Error generating favicon.ico: {e}")