Mostly works! Image loading and caching seems slow
This commit is contained in:
10
src/config/args.py
Normal file
10
src/config/args.py
Normal 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
50
src/config/config.py
Normal 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
|
||||
|
||||
|
@ -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
116
src/rendering/helpers.py
Normal 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
46
src/rendering/image.py
Normal 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
16
src/rendering/markdown.py
Normal 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
188
src/rendering/renderer.py
Normal 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
|
@ -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)
|
||||
|
Reference in New Issue
Block a user