diff --git a/.gitignore b/.gitignore index 505a3b1..3942a83 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ wheels/ # Virtual environments .venv + +example/ diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..0d73f3f --- /dev/null +++ b/config.toml @@ -0,0 +1,4 @@ +[paths] +content_dir = "/home/dubey/projects/foldsite/example/content" +templates_dir = "/home/dubey/projects/foldsite/example/templates" +styles_dir = "/home/dubey/projects/foldsite/example/styles" \ No newline at end of file diff --git a/main.py b/main.py index 94eaf0c..538357c 100644 --- a/main.py +++ b/main.py @@ -1,13 +1,31 @@ -from flask import Flask from src.server.server import Server -from src.routes.routes import default_route +from src.routes.routes import RouteManager +from src.config.args import create_parser +from src.config.config import Configuration +from src.rendering.helpers import TemplateHelpers def main(): + parser = create_parser() + args = parser.parse_args() + + c = Configuration(args.config) + c.load_config() + + r = RouteManager(c) + t = TemplateHelpers(c) + server = Server() - server.register_route("/", default_route, defaults={"path": ""}) - server.register_route("/", default_route) + server.register_template_function("get_sibling_content_files", t.get_sibling_content_files) + server.register_template_function("get_text_document_preview", t.get_text_document_preview) + server.register_template_function("get_sibling_content_folders", t.get_sibling_content_folders) + server.register_template_function("get_folder_contents", t.get_folder_contents) + + server.register_route("/styles/", r.get_style) + server.register_route("/download/", r.get_static) + server.register_route("/", r.default_route, defaults={"path": ""}) + server.register_route("/", r.default_route) server.run() diff --git a/pyproject.toml b/pyproject.toml index 709b83a..8c27c30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,10 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "flask>=3.1.0", + "mistune>=3.1.1", + "pillow>=10.4.0", + "python-frontmatter>=1.1.0", "rich>=13.9.4", + "thumbhash-python>=1.0.1", "waitress>=3.0.2", ] diff --git a/requirements.txt b/requirements.txt index b60c3f9..fca8e10 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,6 +20,10 @@ mdurl==0.1.2 # via markdown-it-py pygments==2.19.1 # via rich +python-frontmatter==1.1.0 + # via foldsite (pyproject.toml) +pyyaml==6.0.2 + # via python-frontmatter rich==13.9.4 # via foldsite (pyproject.toml) waitress==3.0.2 diff --git a/src/config/args.py b/src/config/args.py new file mode 100644 index 0000000..378fac3 --- /dev/null +++ b/src/config/args.py @@ -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 diff --git a/src/config/config.py b/src/config/config.py new file mode 100644 index 0000000..96f4971 --- /dev/null +++ b/src/config/config.py @@ -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 + + diff --git a/src/rendering/__init__.py b/src/rendering/__init__.py index e69de29..457ec41 100644 --- a/src/rendering/__init__.py +++ b/src/rendering/__init__.py @@ -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"] \ No newline at end of file diff --git a/src/rendering/helpers.py b/src/rendering/helpers.py new file mode 100644 index 0000000..6c29a03 --- /dev/null +++ b/src/rendering/helpers.py @@ -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() + ] diff --git a/src/rendering/image.py b/src/rendering/image.py new file mode 100644 index 0000000..baf8025 --- /dev/null +++ b/src/rendering/image.py @@ -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) \ No newline at end of file diff --git a/src/rendering/markdown.py b/src/rendering/markdown.py new file mode 100644 index 0000000..6dda505 --- /dev/null +++ b/src/rendering/markdown.py @@ -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 \ No newline at end of file diff --git a/src/rendering/renderer.py b/src/rendering/renderer.py new file mode 100644 index 0000000..ed83ad2 --- /dev/null +++ b/src/rendering/renderer.py @@ -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 = """ +
+

Error: {{ code }}

+

{{ message }}

+

{{ description }}

+

PS: If you are a theme developer, or the owner of this site, you can style this page with a __error.html file

+
+""" + + +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 diff --git a/src/routes/routes.py b/src/routes/routes.py index f4ddd05..7d2c7ed 100644 --- a/src/routes/routes.py +++ b/src/routes/routes.py @@ -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}" \ No newline at end of file + +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) diff --git a/uv.lock b/uv.lock index 4afb3a7..b82d768 100644 --- a/uv.lock +++ b/uv.lock @@ -54,14 +54,22 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "flask" }, + { name = "mistune" }, + { name = "pillow" }, + { name = "python-frontmatter" }, { name = "rich" }, + { name = "thumbhash-python" }, { name = "waitress" }, ] [package.metadata] requires-dist = [ { name = "flask", specifier = ">=3.1.0" }, + { name = "mistune", specifier = ">=3.1.1" }, + { name = "pillow", specifier = ">=10.4.0" }, + { name = "python-frontmatter", specifier = ">=1.1.0" }, { name = "rich", specifier = ">=13.9.4" }, + { name = "thumbhash-python", specifier = ">=1.0.1" }, { name = "waitress", specifier = ">=3.0.2" }, ] @@ -135,6 +143,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, ] +[[package]] +name = "mistune" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/1d/6b2b634e43bacc3239006e61800676aa6c41ac1836b2c57497ed27a7310b/mistune-3.1.1.tar.gz", hash = "sha256:e0740d635f515119f7d1feb6f9b192ee60f0cc649f80a8f944f905706a21654c", size = 94645 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/02/c66bdfdadbb021adb642ca4e8a5ed32ada0b4a3e4b39c5d076d19543452f/mistune-3.1.1-py3-none-any.whl", hash = "sha256:02106ac2aa4f66e769debbfa028509a275069dcffce0dfa578edd7b991ee700a", size = 53696 }, +] + +[[package]] +name = "pillow" +version = "10.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/74/ad3d526f3bf7b6d3f408b73fde271ec69dfac8b81341a318ce825f2b3812/pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06", size = 46555059 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/00/706cebe7c2c12a6318aabe5d354836f54adff7156fd9e1bd6c89f4ba0e98/pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3", size = 3525685 }, + { url = "https://files.pythonhosted.org/packages/cf/76/f658cbfa49405e5ecbfb9ba42d07074ad9792031267e782d409fd8fe7c69/pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb", size = 3374883 }, + { url = "https://files.pythonhosted.org/packages/46/2b/99c28c4379a85e65378211971c0b430d9c7234b1ec4d59b2668f6299e011/pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70", size = 4339837 }, + { url = "https://files.pythonhosted.org/packages/f1/74/b1ec314f624c0c43711fdf0d8076f82d9d802afd58f1d62c2a86878e8615/pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be", size = 4455562 }, + { url = "https://files.pythonhosted.org/packages/4a/2a/4b04157cb7b9c74372fa867096a1607e6fedad93a44deeff553ccd307868/pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0", size = 4366761 }, + { url = "https://files.pythonhosted.org/packages/ac/7b/8f1d815c1a6a268fe90481232c98dd0e5fa8c75e341a75f060037bd5ceae/pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc", size = 4536767 }, + { url = "https://files.pythonhosted.org/packages/e5/77/05fa64d1f45d12c22c314e7b97398ffb28ef2813a485465017b7978b3ce7/pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a", size = 4477989 }, + { url = "https://files.pythonhosted.org/packages/12/63/b0397cfc2caae05c3fb2f4ed1b4fc4fc878f0243510a7a6034ca59726494/pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309", size = 4610255 }, + { url = "https://files.pythonhosted.org/packages/7b/f9/cfaa5082ca9bc4a6de66ffe1c12c2d90bf09c309a5f52b27759a596900e7/pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060", size = 2235603 }, + { url = "https://files.pythonhosted.org/packages/01/6a/30ff0eef6e0c0e71e55ded56a38d4859bf9d3634a94a88743897b5f96936/pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea", size = 2554972 }, + { url = "https://files.pythonhosted.org/packages/48/2c/2e0a52890f269435eee38b21c8218e102c621fe8d8df8b9dd06fabf879ba/pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d", size = 2243375 }, +] + [[package]] name = "pygments" version = "2.19.1" @@ -144,6 +180,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, ] +[[package]] +name = "python-frontmatter" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/de/910fa208120314a12f9a88ea63e03707261692af782c99283f1a2c8a5e6f/python-frontmatter-1.1.0.tar.gz", hash = "sha256:7118d2bd56af9149625745c58c9b51fb67e8d1294a0c76796dafdc72c36e5f6d", size = 16256 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/87/3c8da047b3ec5f99511d1b4d7a5bc72d4b98751c7e78492d14dc736319c5/python_frontmatter-1.1.0-py3-none-any.whl", hash = "sha256:335465556358d9d0e6c98bbeb69b1c969f2a4a21360587b9873bfc3b213407c1", size = 9834 }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, +] + [[package]] name = "rich" version = "13.9.4" @@ -157,6 +222,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, ] +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, +] + +[[package]] +name = "thumbhash-python" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pillow" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/29/bcb4a01ce7faab0888b00f2eb73e1a6a18b241549ebda61e8e5b31424a37/thumbhash_python-1.0.1.tar.gz", hash = "sha256:9212871c5ddf10487148c767ebd8785c8010f79e378709805bc8729d4293e03d", size = 54381 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/0e/b77ae42436765e3e6323a02df99d6999b82edea7123530448979bbe97045/thumbhash_python-1.0.1-py3-none-any.whl", hash = "sha256:b1bc4ac08ff4354dba70a0caf85a9b3f3522181fbd3e05eb4f03aa786a36578b", size = 9705 }, +] + +[[package]] +name = "typer" +version = "0.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/dca7b219718afd37a0068f4f2530a727c2b74a8b6e8e0c0080a4c0de4fcd/typer-0.15.1.tar.gz", hash = "sha256:a0588c0a7fa68a1978a069818657778f86abe6ff5ea6abf472f940a08bfe4f0a", size = 99789 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/cc/0a838ba5ca64dc832aa43f727bd586309846b0ffb2ce52422543e6075e8a/typer-0.15.1-py3-none-any.whl", hash = "sha256:7994fb7b8155b64d3402518560648446072864beefd44aa2dc36972a5972e847", size = 44908 }, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +] + [[package]] name = "waitress" version = "3.0.2"