Mostly works! Image loading and caching seems slow

This commit is contained in:
2025-02-16 20:36:13 -05:00
parent de565fce4f
commit b2ed4cc4e5
14 changed files with 625 additions and 6 deletions

10
src/config/args.py Normal file
View File

@ -0,0 +1,10 @@
import argparse
def create_parser():
parser = argparse.ArgumentParser(description="foldsite is a dynamic site generator")
parser.add_argument(
"--config", type=str, default="config.toml", help="config file path"
)
return parser

50
src/config/config.py Normal file
View File

@ -0,0 +1,50 @@
from pathlib import Path
import tomllib
CONTENT_DIR = None
TEMPLATES_DIR = None
STYLES_DIR = None
class Configuration:
def __init__(self, config_path):
self.config_path = config_path
self.content_dir: Path = None
self.templates_dir: Path = None
self.styles_dir: Path = None
def load_config(self):
try:
with open(self.config_path, "rb") as f:
self.config_data = tomllib.load(f)
except FileNotFoundError:
raise FileNotFoundError(f"Config file not found at {self.config_path}")
except tomllib.TOMLDecodeError:
raise tomllib.TOMLDecodeError(f"Config file at {self.config_path} is not valid TOML")
paths = self.config_data.get("paths", {})
if not paths:
raise ValueError("Config file does not contain paths section")
self.content_dir = paths.get("content_dir")
if not self.content_dir:
raise ValueError("Config file does not contain content_dir path")
self.content_dir = Path(self.content_dir)
self.templates_dir = paths.get("templates_dir")
if not self.templates_dir:
raise ValueError("Config file does not contain templates_dir path")
self.templates_dir = Path(self.templates_dir)
self.styles_dir = paths.get("styles_dir")
if not self.styles_dir:
raise ValueError("Config file does not contain styles_dir path")
self.styles_dir = Path(self.styles_dir)
def set_globals(self):
global CONTENT_DIR, TEMPLATES_DIR, STYLES_DIR
CONTENT_DIR = self.content_dir
TEMPLATES_DIR = self.templates_dir
STYLES_DIR = self.styles_dir

View File

@ -0,0 +1,10 @@
from collections import OrderedDict
GENERIC_FILE_MAPPING = OrderedDict()
GENERIC_FILE_MAPPING["document"] = ["md", "txt", "html"]
GENERIC_FILE_MAPPING["image"] = ["png", "jpg", "jpeg", "gif", "svg"]
GENERIC_FILE_MAPPING["directory"] = [None]
GENERIC_FILE_MAPPING["other"] = [None]
# Combinations of Groups
GENERIC_FILE_MAPPING["multimedia"] = GENERIC_FILE_MAPPING["image"]

116
src/rendering/helpers.py Normal file
View File

@ -0,0 +1,116 @@
from dataclasses import dataclass
from src.config.config import Configuration
from src.rendering import GENERIC_FILE_MAPPING
from enum import Enum
from thumbhash import image_to_thumbhash
from PIL import Image
@dataclass
class ImageMetadata:
path: str
width: int
height: int
alt: str
thumbhash: str
# exif attributes
exif: dict
@dataclass
class FileMetadata:
path: str
first_hundred_chars: str
@dataclass
class TemplateFile:
name: str
extension: str
categories: list[str]
date_modified: str
date_created: str
size_kb: int
metadata: ImageMetadata | FileMetadata | None
dir_item_count: int
is_dir: bool
class TemplateHelpers:
def __init__(self, config: Configuration):
self.config: Configuration = config
def get_folder_contents(self, path: str = ""):
"""Returns the contents of a folder as a list of TemplateFile objects
The metadata field is populated with the appropriate metadata object
"""
search_contnet_path = self.config.content_dir / path
files = search_contnet_path.glob("*")
ret = []
for f in files:
t = TemplateFile(
name=f.name,
extension=f.suffix.lower(),
categories=[],
date_modified=f.stat().st_mtime,
date_created=f.stat().st_ctime,
size_kb=f.stat().st_size / 1024,
metadata=None,
dir_item_count=len(list(f.glob("*"))) if f.is_dir() else 0,
is_dir=f.is_dir(),
)
if f.is_file():
# Build metadata depending on the mapping in GENERIC_FILE_MAPPING
for k, v in GENERIC_FILE_MAPPING.items():
if f.suffix[1:].lower() in v:
t.categories.append(k)
if k == "image":
img = Image.open(f)
exif = img._getexif()
orientation = exif.get(274, 1) if exif else 1
width, height = img.width, img.height
if orientation in [5, 6, 7, 8]:
width, height = height, width
t.metadata = ImageMetadata(
path=str(f.relative_to(self.config.content_dir)),
width=width,
height=height,
alt=f.name,
thumbhash=image_to_thumbhash(img),
exif=img.info,
)
elif k == "document":
with open(f, "r") as fdoc:
t.metadata = FileMetadata(
path=str(f.relative_to(self.config.content_dir)),
first_hundred_chars=fdoc.read(100),
)
ret.append(t)
return ret
def get_sibling_content_files(self, path: str = ""):
search_contnet_path = self.config.content_dir / path
files = search_contnet_path.glob("*")
return [
(file.name, str(file.relative_to(self.config.content_dir)))
for file in files
if file.is_file()
]
def get_text_document_preview(self, path: str):
file_path = self.config.content_dir / path
with open(file_path, "r") as f:
content = f.read(100)
return content
def get_sibling_content_folders(self, path: str = ""):
search_contnet_path = self.config.content_dir / path
files = search_contnet_path.glob("*")
return [
(file.name, str(file.relative_to(self.config.content_dir)))
for file in files
if file.is_dir()
]

46
src/rendering/image.py Normal file
View File

@ -0,0 +1,46 @@
from PIL import Image
from io import BytesIO
from functools import cache
@cache
def generate_thumbnail(image_path, resize_percent, min_width):
# Generate a unique key based on the image path, resize percentage, and minimum width
key = f"{image_path}_{resize_percent}_{min_width}"
# Open the image file
with Image.open(image_path) as img:
# Calculate the new size based on the resize percentage
width, height = img.size
new_width = int(width * resize_percent / 100)
new_height = int(height * resize_percent / 100)
# Ensure the minimum width is maintained
if new_width < min_width:
scale_factor = min_width / new_width
new_width = min_width
new_height = int(new_height * scale_factor)
# Resize the image while maintaining the aspect ratio
img.thumbnail((new_width, new_height))
# Rotate the image based on the EXIF orientation tag
try:
exif = img._getexif()
orientation = exif.get(0x0112, 1) # 0x0112 is the EXIF orientation tag
if orientation == 3:
img = img.rotate(180, expand=True)
elif orientation == 6:
img = img.rotate(270, expand=True)
elif orientation == 8:
img = img.rotate(90, expand=True)
except (AttributeError, KeyError, IndexError):
# cases: image don't have getexif
pass
# Save the thumbnail to a BytesIO object
thumbnail_io = BytesIO()
img_format = img.format if img.format in ["JPEG", "JPG", "PNG"] else "JPEG"
img.save(thumbnail_io, format=img_format)
thumbnail_io.seek(0)
return (thumbnail_io, img_format)

16
src/rendering/markdown.py Normal file
View File

@ -0,0 +1,16 @@
import frontmatter
import mistune
from pathlib import Path
mistune_render = mistune.create_markdown(
renderer=mistune.HTMLRenderer(escape=False),
plugins=["strikethrough", "footnotes", "table", "task_lists", "abbr", "math"],
)
def render_markdown(path: Path) -> str:
with open(path, "r") as f:
obj = frontmatter.load(f)
res = mistune_render(obj.content)
return res

188
src/rendering/renderer.py Normal file
View File

@ -0,0 +1,188 @@
from pathlib import Path
from typing import Optional
from pprint import pprint as pp
from flask import render_template_string, send_file
from src.rendering import GENERIC_FILE_MAPPING
from src.rendering.markdown import render_markdown
def count_file_extensions(path):
files = path.glob("*")
files_map = {}
for file in files:
if file.is_file():
extension = file.suffix.lower()
if extension in files_map:
files_map[extension] += 1
else:
files_map[extension] = 1
return files_map
def determine_type(path: Path) -> tuple[str, str, Optional[str]]:
if path.is_file():
# Find extension in GENERIC_FILE_MAPPING, it may match multiple
generic_mapping = None
for file_type, extensions in GENERIC_FILE_MAPPING.items():
if path.suffix[1:] in extensions:
if generic_mapping is None:
generic_mapping = [file_type]
else:
generic_mapping.append(file_type)
generic_mapping.reverse()
if generic_mapping is None:
generic_mapping = ["other"]
extension = path.suffix[1:]
return "file", generic_mapping, extension
# Directory
files_map = count_file_extensions(path)
if files_map:
most_seen_extension = max(files_map, key=files_map.get)
generic_mapping = None
for file_type, extensions in GENERIC_FILE_MAPPING.items():
if most_seen_extension[1:] in extensions:
if generic_mapping is None:
generic_mapping = [file_type]
else:
generic_mapping.append(file_type)
generic_mapping.reverse()
if generic_mapping is None:
generic_mapping = ["other"]
return "folder", generic_mapping, most_seen_extension[1:]
else:
return "folder", [], None
DEFAULT_ERROR_TEMPLATE = """
<div class="content">
<h1>Error: {{ code }}</h1>
<p>{{ message }}</p>
<p>{{ description }}</p>
<p>PS: If you are a theme developer, or the owner of this site, you can style this page with a __error.html file</p>
</div>
"""
def render_error_page(
error_code: int,
error_message: str,
error_description: str,
template_path: Path = Path("./"),
):
inp = DEFAULT_ERROR_TEMPLATE
if (template_path / "__error.html").exists():
inp = (template_path / "__error.html").read_text()
content = render_template_string(
inp,
error_code=error_code,
error_message=error_message,
error_description=error_description,
)
return (
render_template_string(
(template_path / "base.html").read_text(),
content=content,
styles=["/base.css", "/__error.css"],
),
error_code,
)
def render_page(
path: Path,
base_path: Path = Path("./"),
template_path: Path = Path("./"),
style_path: Path = Path("./"),
):
if not path.exists():
return render_error_page(
404,
"Not Found",
"The requested resource was not found on this server.",
template_path,
)
target_path = path
target_file = path
if path.is_file():
target_path = path.parent
type, category, extension = determine_type(target_file)
relative_path = target_file.relative_to(base_path)
relative_dir = target_path.relative_to(base_path)
# Generate the possible paths for style
styles = []
styles.append("/" + str(relative_path) + ".css")
search_path = style_path / relative_dir
while search_path >= style_path:
if (search_path / f"__{type}.{extension}.css").exists():
styles.append(
"/"
+ str(search_path.relative_to(style_path))
+ f"/__{type}.{extension}.css"
)
for c in reversed(category):
if (search_path / f"__{type}.{c}.css").exists():
styles.append(
"/"
+ str(search_path.relative_to(style_path))
+ f"/__{type}.{c}.css"
)
search_path = search_path.parent
styles.append("/base.css")
styles = [t for t in styles if (style_path / t[1:]).exists()]
templates = []
if type == "folder":
if (template_path / relative_dir / "__folder.html").exists():
templates.append(relative_dir / "__folder.html")
else:
if (template_path / (str(relative_path) + ".html")).exists():
templates.append(template_path / (str(relative_path) + ".html"))
if len(templates) == 0:
search_path = template_path / relative_dir
while search_path >= template_path:
if (search_path / f"__{type}.{extension}.html").exists():
templates.append(search_path / f"__{type}.{extension}.html")
break
for c in reversed(category):
if (search_path / f"__{type}.{c}.html").exists():
templates.append(search_path / f"__{type}.{c}.html")
break
search_path = search_path.parent
if len(templates) == 0:
if type == "file":
return send_file(target_file)
else:
return render_error_page(
404,
"Not Found",
"The requested resource was not found on this server.",
template_path,
)
content = ""
if "document" in category and type == "file":
content = render_markdown(target_file)
if not (template_path / "base.html").exists():
raise Exception("Base template not found")
templates.append(template_path / "base.html")
# Filter templates to only those that exist
for template in templates:
content = render_template_string(
template.read_text(),
content=content,
styles=styles,
currentPath=str(relative_path),
)
return content

View File

@ -1,3 +1,43 @@
from pathlib import Path
from src.config.config import Configuration
from src.rendering.renderer import render_page, render_error_page
from flask import send_file
from src.rendering.image import generate_thumbnail
from functools import lru_cache
def default_route(path: str):
return f"Default route for {path}"
class RouteManager:
def __init__(self, config: Configuration):
self.config = config
def default_route(self, path: str):
file_path: Path = self.config.content_dir / (path if path else "index.md")
return render_page(
file_path,
base_path=self.config.content_dir,
template_path=self.config.templates_dir,
style_path=self.config.styles_dir,
)
def get_style(self, path: str):
file_path: Path = self.config.styles_dir / path
if file_path.exists():
return send_file(file_path)
else:
return render_error_page(404, "Not Found", "The requested resource was not found on this server.", self.config.templates_dir)
@lru_cache(maxsize=128)
def get_static(self, path: str):
file_path: Path = self.config.content_dir / path
if file_path.exists():
# Check to see if the file is an image, if it is, render a thumbnail
if file_path.suffix.lower() in [".jpg", ".jpeg", ".png", ".gif"]:
thumbnail_bytes, img_format = generate_thumbnail(str(file_path), 10, 300)
return (
thumbnail_bytes.getvalue(),
200,
{"Content-Type": f"image/{img_format.lower()}"},
)
return send_file(file_path)
else:
return render_error_page(404, "Not Found", "The requested resource was not found on this server.", self.config.templates_dir)