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"