From 4d705e267e64ae27077175912acdd427d7bf4349 Mon Sep 17 00:00:00 2001 From: stupidcomputer Date: Sun, 5 Jan 2025 15:16:17 -0600 Subject: [PATCH] rewrite in python -- mostly feature complete --- .gitignore | 1 + derivation.nix | 10 ++++ sample_project/.gitignore | 1 + sample_project/src/main.css | 3 ++ sample_project/src/main.md | 9 ++++ sample_project/src/testing/index.md | 5 ++ sample_project/templates/article.html | 4 ++ sample_project/templates/base.html | 9 ++++ setup.py | 24 +++++++++ shell.nix | 10 ++++ sssg.sh | 77 --------------------------- sssg/__main__.py | 4 ++ sssg/cli.py | 17 ++++++ sssg/converters.py | 56 +++++++++++++++++++ sssg/document.py | 71 ++++++++++++++++++++++++ sssg/liveupdate.py | 31 +++++++++++ sssg/site.py | 72 +++++++++++++++++++++++++ testing.nix | 7 +++ 18 files changed, 334 insertions(+), 77 deletions(-) create mode 100644 .gitignore create mode 100644 derivation.nix create mode 100644 sample_project/.gitignore create mode 100644 sample_project/src/main.css create mode 100644 sample_project/src/main.md create mode 100644 sample_project/src/testing/index.md create mode 100644 sample_project/templates/article.html create mode 100644 sample_project/templates/base.html create mode 100644 setup.py create mode 100644 shell.nix delete mode 100755 sssg.sh create mode 100644 sssg/__main__.py create mode 100644 sssg/cli.py create mode 100644 sssg/converters.py create mode 100644 sssg/document.py create mode 100644 sssg/liveupdate.py create mode 100644 sssg/site.py create mode 100644 testing.nix diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/derivation.nix b/derivation.nix new file mode 100644 index 0000000..d4ec9f6 --- /dev/null +++ b/derivation.nix @@ -0,0 +1,10 @@ +{ python3Packages }: +with python3Packages; +buildPythonApplication { + pname = "sssg-py"; + version = "1.0"; + + propagatedBuildInputs = [ markdown jinja2 watchdog ]; + + src = ./.; +} diff --git a/sample_project/.gitignore b/sample_project/.gitignore new file mode 100644 index 0000000..ea1472e --- /dev/null +++ b/sample_project/.gitignore @@ -0,0 +1 @@ +output/ diff --git a/sample_project/src/main.css b/sample_project/src/main.css new file mode 100644 index 0000000..e0a53ad --- /dev/null +++ b/sample_project/src/main.css @@ -0,0 +1,3 @@ +thsi file doesn't have to be css' + +it's just a test' diff --git a/sample_project/src/main.md b/sample_project/src/main.md new file mode 100644 index 0000000..3bb7143 --- /dev/null +++ b/sample_project/src/main.md @@ -0,0 +1,9 @@ +--- +title: Page root +--- + +# This is a test! + +testingk + +[to the testing subdirectory](./testing) diff --git a/sample_project/src/testing/index.md b/sample_project/src/testing/index.md new file mode 100644 index 0000000..0d4929f --- /dev/null +++ b/sample_project/src/testing/index.md @@ -0,0 +1,5 @@ +this is tsate + +this is another test + +tseting diff --git a/sample_project/templates/article.html b/sample_project/templates/article.html new file mode 100644 index 0000000..8992131 --- /dev/null +++ b/sample_project/templates/article.html @@ -0,0 +1,4 @@ +{% extends 'base.html' %} +{% block body_content %} +{{ md_html|safe }} +{% endblock body_content %} diff --git a/sample_project/templates/base.html b/sample_project/templates/base.html new file mode 100644 index 0000000..98ff6a2 --- /dev/null +++ b/sample_project/templates/base.html @@ -0,0 +1,9 @@ + + + testing + + + {% block body_content %} + {% endblock %} + + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..9e038a8 --- /dev/null +++ b/setup.py @@ -0,0 +1,24 @@ +from setuptools import setup, find_packages + +setup( + name = 'sssg', + version = '1.0.0', + author = 'stupidcomputer', + author_email = 'ryan@beepboop.systems', + url = 'https://git.beepboop.systems/stupidcomputer/sssg', + description = 'the stupid static site generator', + license = 'GPLv3', + entry_points = { + 'console_scripts': [ + 'sssg = sssg.__main__:main' + ] + }, + packages=["sssg"], + classifiers = ( + "Programming Language :: Python :: 3", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Operating System :: POSIX :: Linux", + "Environment :: Console" + ), + zip_safe = False +) diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..994bed6 --- /dev/null +++ b/shell.nix @@ -0,0 +1,10 @@ +{ pkgs ? import {} }: + pkgs.mkShell { + nativeBuildInputs = [ + pkgs.python3 + pkgs.python311Packages.markdown + pkgs.python311Packages.jinja2 + pkgs.python311Packages.watchdog + ]; + } + diff --git a/sssg.sh b/sssg.sh deleted file mode 100755 index 616bb65..0000000 --- a/sssg.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/bin/sh - -if [ "$1" = "-h" ]; then - cat < output/changelog.rst -pandoc -s --template=./template.html -f rst -t html -o "output/changelog.html" "output/changelog.rst" -rm changelog.rst - -set -x -for i in $files; do - without_extension=${i%.*} - case $i in - *.rst) - pandoc -s --template=./template.html -f rst -t html -o "output/$without_extension.html" "$without_extension.rst" - ;; - "./ssg.sh") # don't copy this file - ;; - "./shell.nix") # ditto - ;; - "./template.html") - ;; - *) - cp "$i" "output/$i" - ;; - esac -done -set +x diff --git a/sssg/__main__.py b/sssg/__main__.py new file mode 100644 index 0000000..9ae637f --- /dev/null +++ b/sssg/__main__.py @@ -0,0 +1,4 @@ +from .cli import main + +if __name__ == "__main__": + main() diff --git a/sssg/cli.py b/sssg/cli.py new file mode 100644 index 0000000..e58e77e --- /dev/null +++ b/sssg/cli.py @@ -0,0 +1,17 @@ +from sssg.site import Site +import sssg.liveupdate # circular dependency resolution + +import sys +import os + +def main(args=sys.argv): + command = args[1] + + if command == "rebuild": + cwd = os.getcwd() + site = Site.from_directory(cwd) + site.rebuild() + elif command == "deploy": + os.execv('/bin/sh', ['/bin/sh', '.deploy']) + elif command == "update": + sssg.liveupdate.liveupdate() diff --git a/sssg/converters.py b/sssg/converters.py new file mode 100644 index 0000000..ed2c131 --- /dev/null +++ b/sssg/converters.py @@ -0,0 +1,56 @@ +from sssg.document import DocumentType, Document +from markdown import Markdown +from jinja2 import Environment +from typing import Self +import os + +class Converter: + document: Document + jinja_environment: Environment + def __init__(self: Self, document: Document, environment: Environment): + self.document = document + self.jinja_environment = environment + + def realize_to_output(self: Self): + # should be subclassed + pass + +class ConverterConfiguration: + converters = dict[DocumentType, Converter] + jinja_environment: Environment + + def __init__(self: Self, converters: dict[DocumentType, Converter], environment: Environment): + self.converters = converters + self.jinja_environment = environment + + def convert(self: Self, document: Document): + self.converters[document.doc_type](document, self.jinja_environment).realize_to_output() + +class BasicConverter(Converter): + def realize_to_output(self: Self): + with open(self.document.file_path, "r") as file: + contents = file.read() + md = Markdown(extensions=["meta", "fenced_code", "tables"]) + html = md.convert(contents) + + template = self.jinja_environment.get_template("article.html") + + with open(self.document.result_path, "w") as file: + file.write(template.render(md_html=html)) + +BlogConverter = BasicConverter + +class BinaryConverter(Converter): + def realize_to_output(self: Self): + with open(self.document.file_path, "r") as origin: + with open(self.document.result_path, "w") as target: + target.write(origin.read()) + +class SensibleConverterConfiguration(ConverterConfiguration): + def __init__(self: Self, environment: Environment): + self.jinja_environment = environment + self.converters = { + DocumentType.generic_markdown: BasicConverter, + DocumentType.blog: BlogConverter, + DocumentType.binary: BinaryConverter, + } diff --git a/sssg/document.py b/sssg/document.py new file mode 100644 index 0000000..2360868 --- /dev/null +++ b/sssg/document.py @@ -0,0 +1,71 @@ +import markdown + +from dataclasses import dataclass +from typing import Self +from enum import auto, Enum + +class DocumentType(Enum): + generic_markdown = auto() + blog = auto() + binary = auto() + +@dataclass +class Document: + doc_type: DocumentType + file_path: str + + @property + def result_path(self: Self) -> str: + # change the 'src' to 'output' -- loop backwards and replace + splitted = self.file_path.split('/') + found_src = False + # can't use reversed(enumerate(...)) -- so use a workaround + for index, value in sorted(enumerate(splitted), reverse=True): + if value == "src": + splitted[index] = "output" + found_src = True + break + + if not found_src: + raise ValueError("Your source directory must be named 'src/'") + + file_path = '/'.join(splitted) + + if self.doc_type == DocumentType.binary: + return file_path + elif ( + self.doc_type == DocumentType.generic_markdown or + self.doc_type == DocumentType.blog + ): + splitted = file_path.split('.') + return '.'.join(splitted[:-1] + ["html"]) + + @classmethod + def from_filepath(cls: Self, file_path: str) -> Self: + # this is stupid, but try to detect DocumentType from the file + # extension, and then disambiguate into the DocumentType.blog and + # DocumentType.generic_markdown cases as needed + + is_markdown = file_path.endswith(".md") + if is_markdown: + with open(file_path, "r") as file: + contents = file.read() + md = markdown.Markdown(extensions=["meta"]) + md.convert(contents) + + try: + is_blog = md.Meta["blog"] == "yes" + except KeyError: + is_blog = False + + if is_blog: + doc_type = DocumentType.blog + else: + doc_type = DocumentType.generic_markdown + else: + doc_type = DocumentType.binary + + return cls( + doc_type=doc_type, + file_path=file_path + ) diff --git a/sssg/liveupdate.py b/sssg/liveupdate.py new file mode 100644 index 0000000..f137974 --- /dev/null +++ b/sssg/liveupdate.py @@ -0,0 +1,31 @@ +import time +import importlib +import os +from watchdog.observers import Observer +from watchdog.events import LoggingEventHandler +import sssg.cli # circular dependency resolution + +class Handler(LoggingEventHandler): + def on_modified(self, event): + if os.path.isdir(event.src_path): + print("{} was ignored (directory)".format(event.src_path)) + return + print("{} triggered reload".format(event.src_path)) + time.sleep(0.25) # reduce some nvim race conditions + sssg.cli.main(["", "rebuild"]) + + on_created = on_modified + +def liveupdate(): + event_handler = Handler() + observer = Observer() + observer.schedule(event_handler, './src', recursive=True) + observer.schedule(event_handler, './templates', recursive=True) + observer.start() + print("observer started") + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + observer.stop() + observer.join() diff --git a/sssg/site.py b/sssg/site.py new file mode 100644 index 0000000..7ede628 --- /dev/null +++ b/sssg/site.py @@ -0,0 +1,72 @@ +import os +from jinja2 import Environment, FileSystemLoader +from typing import Self +from pathlib import Path + +from sssg.document import Document, DocumentType +from sssg.converters import SensibleConverterConfiguration + +def dir_walk(dirpath: str) -> list[str]: + filepaths: list[str] = [] + for root, dirs, files in os.walk(dirpath): + for filename in files: + complete = os.path.join(root, filename) + print(complete) + filepaths.append(complete) + for dirname in dirs: + complete = os.path.join(root, dirname) + print(complete) + filepaths += dir_walk(complete) + + filepaths = [path for path in filepaths if '~' not in path] + print(filepaths) + return filepaths + +class Site: + dir_path: str + documents: list[Document] + jinja_environment: Environment + + def __init__( + self: Self, + dir_path: str, + documents: list[Document], + ): + self.dir_path = dir_path + self.documents = documents + self.jinja_environment = Environment( + loader=FileSystemLoader( + os.path.join(self.dir_path, "templates") + ) + ) + + def _ensure_output_directories(self: Self): + output_dirs = [os.path.dirname(doc.result_path) for doc in self.documents] + # remove duplicates + output_dirs = list(set(output_dirs)) + + for path in output_dirs: + Path(path).mkdir(parents=True, exist_ok=True) + + def rebuild( + self: Self, + ): + self._ensure_output_directories() + config = SensibleConverterConfiguration(self.jinja_environment) + for document in self.documents: + config.convert(document) + + @classmethod + def from_directory(cls: Self, dir_path: str): + src_dir = os.path.join(dir_path, "src") + paths = dir_walk(src_dir) + paths = list(set(paths)) + documents = [ + Document.from_filepath(path) + for path in paths + ] + + return cls( + dir_path=dir_path, + documents=documents + ) diff --git a/testing.nix b/testing.nix new file mode 100644 index 0000000..9b4f265 --- /dev/null +++ b/testing.nix @@ -0,0 +1,7 @@ +{ pkgs ? import {} }: + pkgs.mkShell { + nativeBuildInputs = [ + ( pkgs.callPackage ./derivation.nix {} ) + ]; + } +