diff --git a/README.md b/README.md index 8fc4fed..ed9eb2e 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ for programming knowledge — simply modify YAML files to generate your site. - **Markdown Support**: Write content in Markdown, which is automatically converted to HTML. - **Tagging System**: Organize your content with tags for easy referencing in templates. - **File Inclusion**: Include other YAML files to create a modular content structure. +- **Plugin System**: Extend the functionality with modules that can be added at runtime. ## Getting Started @@ -26,7 +27,7 @@ To generate your static site, run the Grimoire command with your input YAML file You can specify an output directory using the `-o` or `--output` flag. ```bash -python -m grimoire-ssg -o output_directory one_or_more_input_files.yml +python -m grimoiressg -o output_directory one_or_more_input_files.yml ``` ### Alternative Installation @@ -43,7 +44,7 @@ poetry install You can then run the program directly using Poetry: ```bash -poetry run python -m grimoire-ssg -o output_directory one_or_more_input_files.yml +poetry run python -m grimoiressg -o output_directory one_or_more_input_files.yml ``` ### Example YAML File @@ -101,7 +102,11 @@ extends a layout and includes dynamic content:

My latest blog articles:

{% endblock %} @@ -126,7 +131,43 @@ Additionally, the following fields are defined: ### Output Structure -The output files will be generated in the specified output directory, with paths defined in the `output` attribute of your YAML files. +The output files will be generated in the specified output directory, with paths defined in the `output` +attribute of your YAML files. + +## Advanced Features + +### Custom Plugins + +The program supports the addition of custom plugins at runtime. To utilize this, create a Python module +that modifies the list of available modules: + +```Python +from grimoiressg.modules import available_modules +from grimoiressg.utils import logger + + +def test(data, context): + logger.info("This is test module.") + + +available_modules["test"] = test + +``` + +You then need a config file that loads, and enables this module. Please note that you need to specify +all `enabled_modules` to be used - not just the additional one. + +```yaml +load_modules: + - external_module_test + +enabled_modules: + - tags # built-in module for tagging + - markdown # built-in module for markdown support + - templating # built-in module for templating + - test # our custom module; the name is the + # key in the `available_modules` dict above +``` ## Contributing @@ -134,7 +175,7 @@ Contributions are welcome! If you have suggestions or improvements, feel free to ## License -This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. +This project is licensed under the BSD-2-Clause License. See the [LICENSE](LICENSE) file for details. ## Acknowledgments diff --git a/example/config.yml b/example/config.yml new file mode 100644 index 0000000..9a65a5b --- /dev/null +++ b/example/config.yml @@ -0,0 +1,8 @@ +load_modules: + - external_module_test + +enabled_modules: + - tags + - markdown + - templating + - test \ No newline at end of file diff --git a/external_module_test/__init__.py b/external_module_test/__init__.py new file mode 100644 index 0000000..5a59c42 --- /dev/null +++ b/external_module_test/__init__.py @@ -0,0 +1,9 @@ +from grimoiressg.modules import available_modules +from grimoiressg.utils import logger + + +def test(data, context): + logger.info("This is test module.") + + +available_modules["test"] = test diff --git a/grimoire-ssg/__main__.py b/grimoire-ssg/__main__.py deleted file mode 100644 index b89a532..0000000 --- a/grimoire-ssg/__main__.py +++ /dev/null @@ -1,131 +0,0 @@ -import argparse -import glob -import os - -import markdown -import yaml -from jinja2 import Environment, FileSystemLoader -from yaml import Loader - -jinja_env = Environment( - loader=FileSystemLoader("/") -) - - -def to_relative(path): - trimmed = path.removeprefix(os.getcwd()) - if trimmed != path: - trimmed = "." + trimmed - return trimmed - - -def compile_markdown(data): - for entry in data: - if "markdown" in entry: - print(f"Compiling markdown for {entry['relative_filename']}...") - entry["markdown_compiled"] = markdown.markdown(entry["markdown"]) - - -def render(data, tags, output_dir): - files_written = 0 - - for entry in data: - if "template" in entry: - template_path = os.path.realpath(os.path.dirname(entry["filename"]) + "/" + entry["template"]) - template_dir = os.path.dirname(template_path) - print(f"Rendering template for {entry['relative_filename']}...") - template = jinja_env.get_template(template_path) - entry["rendered"] = template.render(current=entry, all=data, tags=tags, template_dir=template_dir) - - if "rendered" in entry and "output" in entry: - files_written += 1 - filename = os.path.realpath(output_dir + "/" + entry["output"]) - print(f" ... writing to {to_relative(filename)}") - os.makedirs(os.path.dirname(filename), exist_ok=True) - with open(filename, "w") as file: - file.write(entry["rendered"]) - - return files_written - - -def extract_tags(data): - tags = {} - - for entry in data: - for tag in entry.get("tags", []): - entry_list = tags.get(tag, []) - entry_list.append(entry) - tags[tag] = entry_list - - print(f"Found tags: " + repr(list(tags.keys()))) - - return tags - - -def handle_file_or_glob(globname): - results = [] - - for filename in glob.glob(os.path.realpath(globname)): - results.extend(handle_file(filename)) - - return results - - -def handle_file(filename): - print(f"Reading {to_relative(filename)}...") - - with open(filename, "r") as file: - data = yaml.load(file, Loader) - - data["filename"] = filename - data["relative_filename"] = to_relative(filename) - results = [data] - - relative_dir = os.path.dirname(filename) - for filename in data.get("include", []): - filename = relative_dir + "/" + filename - sub_data = handle_file_or_glob(filename) - results.extend(sub_data) - - return results - - -def parse_arguments(): - parser = argparse.ArgumentParser() - parser.add_argument("-o", "--output", default="./output/") - - args, filenames = parser.parse_known_args() - - return args.output, filenames - - -def main(): - output_dir, filenames = parse_arguments() - - print(f"Output directory: {output_dir}") - print(f"Initial filenames: {filenames}") - print() - - if len(filenames) == 0: - print("error: at least one filename needed") - exit(1) - - data = [] - for filename in filenames: - data.extend(handle_file_or_glob(filename)) - - print(f"Total number of entries: {len(data)}") - print() - - compile_markdown(data) - tags = extract_tags(data) - files_written = render(data, tags, output_dir) - - print(f"Total files written: {files_written}") - - print() - print("Done.") - - -if __name__ == "__main__": - main() diff --git a/grimoiressg/__main__.py b/grimoiressg/__main__.py new file mode 100644 index 0000000..b8b38cb --- /dev/null +++ b/grimoiressg/__main__.py @@ -0,0 +1,28 @@ +import logging + +from grimoiressg.arguments import parse_arguments_to_initial_context +from grimoiressg.config import read_config +from grimoiressg.content_files import recursively_read_files +from grimoiressg.modules import available_modules +from grimoiressg.utils import logger + + +def apply_modules(data, config, context): + for module in config.get("enabled_modules", []): + logger.info("Applying module %s...", module) + available_modules[module](data, context) + + +def main(): + context = parse_arguments_to_initial_context() + config = read_config(context) + + data = recursively_read_files(context) + apply_modules(data, config, context) + + logger.info("Done.") + logging.shutdown() + + +if __name__ == "__main__": + main() diff --git a/grimoiressg/arguments.py b/grimoiressg/arguments.py new file mode 100644 index 0000000..a062299 --- /dev/null +++ b/grimoiressg/arguments.py @@ -0,0 +1,32 @@ +import argparse + +from grimoiressg.utils import logger + + +def parse_arguments_to_initial_context(): + parser = argparse.ArgumentParser( + description=''' + Grimoire is a minimalistic Static Site Generator. + In the simplest case the only argument needed is at least one content file. \ + The rest of the flags is used to customize the behavior. + ''' + ) + parser.add_argument("content_file", nargs="+", help="one or more content files") + parser.add_argument("-o", "--output", default="./output/", help="the output directory (default: ./output/)") + parser.add_argument("-c", "--config", help="the config file to use") + + args, _ = parser.parse_known_args() + + context = { + "output_dir": args.output, + "config_file": args.config, + "filenames": args.content_file + } + + logger.debug("Output directory: %s", context['output_dir']) + logger.debug("Config file: %s", context['config_file']) + logger.debug("Content files:") + for filename in context["filenames"]: + logger.debug(" - %s", filename) + + return context diff --git a/grimoiressg/config.py b/grimoiressg/config.py new file mode 100644 index 0000000..ce4ebe4 --- /dev/null +++ b/grimoiressg/config.py @@ -0,0 +1,43 @@ +import logging + +import yaml +from yaml import Loader + +from grimoiressg.modules import available_modules, load_external_module +from grimoiressg.utils import logger + + +def default_config(): + return { + "enabled_modules": [ + "tags", + "markdown", + "templating" + ] + } + + +def read_config(context): + config_file = context.get("config_file", None) + + if not config_file: + logger.info("No config file given; using default config") + config = default_config() + else: + logger.info("Loading config file...") + with open(config_file, "r") as file: + config = yaml.load(file, Loader) or {} + + for module in config.get("load_modules", []): + logger.debug(" Loading external module %s", module) + load_external_module(module) + + logger.debug("Enabled modules:") + for module in config.get("enabled_modules", []): + logger.debug(" - %s", module) + if module not in available_modules: + logger.critical("Module does not exist: %s", module) + logging.shutdown() + exit(1) + + return config diff --git a/grimoiressg/content_files.py b/grimoiressg/content_files.py new file mode 100644 index 0000000..854d6af --- /dev/null +++ b/grimoiressg/content_files.py @@ -0,0 +1,38 @@ +import os + +import yaml +from yaml import Loader + +from grimoiressg.utils import logger, for_each_glob, to_relative + + +def handle_file(filename): + logger.debug(" Reading %s...", to_relative(filename)) + + with open(filename, "r") as file: + data = yaml.load(file, Loader) + + data["filename"] = filename + data["relative_filename"] = to_relative(filename) + results = [data] + + relative_dir = os.path.dirname(filename) + for filename in data.get("include", []): + filename = relative_dir + "/" + filename + sub_data = for_each_glob(filename, handle_file) + results.extend(sub_data) + + return results + + +def recursively_read_files(context): + data = [] + + logger.info("Reading content files...") + + for filename in context["filenames"]: + data.extend(for_each_glob(filename, handle_file)) + + logger.info(f"Read %d files in total.", len(data)) + + return data diff --git a/grimoiressg/modules/__init__.py b/grimoiressg/modules/__init__.py new file mode 100644 index 0000000..133e55f --- /dev/null +++ b/grimoiressg/modules/__init__.py @@ -0,0 +1,13 @@ +from grimoiressg.modules.markdown import compile_markdown +from grimoiressg.modules.tags import extract_tags +from grimoiressg.modules.templating import render_templates + +available_modules = { + "tags": extract_tags, + "markdown": compile_markdown, + "templating": render_templates +} + + +def load_external_module(module): + __import__(module) diff --git a/grimoiressg/modules/markdown.py b/grimoiressg/modules/markdown.py new file mode 100644 index 0000000..2202277 --- /dev/null +++ b/grimoiressg/modules/markdown.py @@ -0,0 +1,10 @@ +import markdown + +from grimoiressg.utils import logger + + +def compile_markdown(data, context): + for entry in data: + if "markdown" in entry: + logger.debug("Compiling markdown for %s...", entry['relative_filename']) + entry["markdown_compiled"] = markdown.markdown(entry["markdown"]) diff --git a/grimoiressg/modules/tags.py b/grimoiressg/modules/tags.py new file mode 100644 index 0000000..1d63cf7 --- /dev/null +++ b/grimoiressg/modules/tags.py @@ -0,0 +1,20 @@ +from grimoiressg.utils import logger + + +def extract_tags(data, context): + tags = {} + + for entry in data: + for tag in entry.get("tags", []): + entry_list = tags.get(tag, []) + entry_list.append(entry) + tags[tag] = entry_list + + if tags: + logger.debug("Found tags:") + for tag in tags.keys(): + logger.debug(" - %s (%d files)", tag, len(tags[tag])) + else: + logger.debug("No tags found.") + + context["tags"] = tags \ No newline at end of file diff --git a/grimoiressg/modules/templating.py b/grimoiressg/modules/templating.py new file mode 100644 index 0000000..ef2e307 --- /dev/null +++ b/grimoiressg/modules/templating.py @@ -0,0 +1,36 @@ +import os + +from jinja2 import Environment, FileSystemLoader + +from grimoiressg.utils import to_relative, logger + +jinja_env = Environment( + loader=FileSystemLoader("/") +) + + +def render_templates(data, context): + files_written = 0 + + for entry in data: + if "template" in entry: + template_path = os.path.realpath(os.path.dirname(entry["filename"]) + "/" + entry["template"]) + template_dir = os.path.dirname(template_path) + logger.debug("Rendering template for %s...", entry['relative_filename']) + template = jinja_env.get_template(template_path) + entry["rendered"] = template.render( + **context, + current=entry, + all=data, + template_dir=template_dir + ) + + if "rendered" in entry and "output" in entry: + files_written += 1 + filename = os.path.realpath(context["output_dir"] + "/" + entry["output"]) + logger.debug(" writing to %s", to_relative(filename)) + os.makedirs(os.path.dirname(filename), exist_ok=True) + with open(filename, "w") as file: + file.write(entry["rendered"]) + + logger.debug("%d rendered", files_written) diff --git a/grimoiressg/utils/__init__.py b/grimoiressg/utils/__init__.py new file mode 100644 index 0000000..c90e260 --- /dev/null +++ b/grimoiressg/utils/__init__.py @@ -0,0 +1,2 @@ +from .files import to_relative as to_relative, for_each_glob as for_each_glob +from .logger import logger as logger diff --git a/grimoiressg/utils/files.py b/grimoiressg/utils/files.py new file mode 100644 index 0000000..c3ccf31 --- /dev/null +++ b/grimoiressg/utils/files.py @@ -0,0 +1,18 @@ +import glob +import os + + +def to_relative(path): + trimmed = path.removeprefix(os.getcwd()) + if trimmed != path: + trimmed = "." + trimmed + return trimmed + + +def for_each_glob(glob_path, callback): + results = [] + + for filename in glob.glob(os.path.realpath(glob_path)): + results.extend(callback(filename)) + + return results diff --git a/grimoiressg/utils/logger.py b/grimoiressg/utils/logger.py new file mode 100644 index 0000000..cae4f43 --- /dev/null +++ b/grimoiressg/utils/logger.py @@ -0,0 +1,5 @@ +import logging +import sys + +logging.basicConfig(stream=sys.stdout, level=logging.INFO, format="%(asctime)s %(levelname)-9s: %(message)s") +logger = logging.getLogger("grimoire") diff --git a/pyproject.toml b/pyproject.toml index 85bfcfe..a5a875b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] name = "grimoire-ssg" packages = [ - { include = "grimoire-ssg" } + { include = "grimoiressg" } ] version = "0.1.0" description = "A minimalistic Static Site Generator"