From 8fe474561a4011fa5b63a9b472e726f39cb3ec1a Mon Sep 17 00:00:00 2001
From: sigmasternchen <git@sigma-star.io>
Date: Tue, 7 Jan 2025 09:45:05 +0100
Subject: [PATCH 1/7] docs: Fix wrong license in README

---
 README.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/README.md b/README.md
index 8fc4fed..a564fb8 100644
--- a/README.md
+++ b/README.md
@@ -134,7 +134,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
 

From b2d8198d1daa167ccd4db83e1f13076827702d4d Mon Sep 17 00:00:00 2001
From: sigmasternchen <git@sigma-star.io>
Date: Tue, 7 Jan 2025 20:14:49 +0100
Subject: [PATCH 2/7] refactor: Extract individual features into toggleable
 modules

---
 grimoire-ssg/__main__.py          | 131 ------------------------------
 grimoiressg/__main__.py           |  25 ++++++
 grimoiressg/arguments.py          |  31 +++++++
 grimoiressg/config.py             |  35 ++++++++
 grimoiressg/content_files.py      |  39 +++++++++
 grimoiressg/modules/__init__.py   |   9 ++
 grimoiressg/modules/markdown.py   |   8 ++
 grimoiressg/modules/tags.py       |  18 ++++
 grimoiressg/modules/templating.py |  36 ++++++++
 grimoiressg/utils/__init__.py     |   1 +
 grimoiressg/utils/files.py        |  18 ++++
 pyproject.toml                    |   2 +-
 12 files changed, 221 insertions(+), 132 deletions(-)
 delete mode 100644 grimoire-ssg/__main__.py
 create mode 100644 grimoiressg/__main__.py
 create mode 100644 grimoiressg/arguments.py
 create mode 100644 grimoiressg/config.py
 create mode 100644 grimoiressg/content_files.py
 create mode 100644 grimoiressg/modules/__init__.py
 create mode 100644 grimoiressg/modules/markdown.py
 create mode 100644 grimoiressg/modules/tags.py
 create mode 100644 grimoiressg/modules/templating.py
 create mode 100644 grimoiressg/utils/__init__.py
 create mode 100644 grimoiressg/utils/files.py

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..0b4004e
--- /dev/null
+++ b/grimoiressg/__main__.py
@@ -0,0 +1,25 @@
+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
+
+
+def apply_modules(data, config, context):
+    for module in config.get("enabled_modules", []):
+        print(f"Applying module {module}...")
+        available_modules[module](data, context)
+        print("")
+
+
+def main():
+    context = parse_arguments_to_initial_context()
+    config = read_config(context)
+
+    data = recursively_read_files(context)
+    apply_modules(data, config, context)
+
+    print("Done.")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/grimoiressg/arguments.py b/grimoiressg/arguments.py
new file mode 100644
index 0000000..22dd6b8
--- /dev/null
+++ b/grimoiressg/arguments.py
@@ -0,0 +1,31 @@
+import argparse
+
+
+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
+    }
+
+    print(f"Output directory: {context['output_dir']}")
+    print(f"Config file: {context['config_file']}")
+    print("Content files:")
+    for filename in context["filenames"]:
+        print(f" - {filename}")
+    print()
+
+    return context
diff --git a/grimoiressg/config.py b/grimoiressg/config.py
new file mode 100644
index 0000000..ada4269
--- /dev/null
+++ b/grimoiressg/config.py
@@ -0,0 +1,35 @@
+import yaml
+from yaml import Loader
+
+from grimoiressg.modules import available_modules
+
+
+def default_config():
+    return {
+        "enabled_modules": [
+            "tags",
+            "markdown",
+            "templating"
+        ]
+    }
+
+
+def read_config(context):
+    config_file = context.get("config_file", None)
+
+    if not config_file:
+        print("No config file given; using default config")
+        config = default_config()
+    else:
+        with open(config_file, "r") as file:
+            config = yaml.load(file, Loader) or {}
+
+    print("Enabled modules:")
+    for module in config.get("enabled_modules", []):
+        print(f" - {module}")
+        if module not in available_modules:
+            print(f"    ERROR: Module does not exist")
+            exit(1)
+    print()
+
+    return config
diff --git a/grimoiressg/content_files.py b/grimoiressg/content_files.py
new file mode 100644
index 0000000..0c3f299
--- /dev/null
+++ b/grimoiressg/content_files.py
@@ -0,0 +1,39 @@
+import os
+
+import yaml
+from yaml import Loader
+
+from grimoiressg.utils.files import for_each_glob, to_relative
+
+
+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 = for_each_glob(filename, handle_file)
+        results.extend(sub_data)
+
+    return results
+
+
+def recursively_read_files(context):
+    data = []
+
+    print("Reading content files...")
+
+    for filename in context["filenames"]:
+        data.extend(for_each_glob(filename, handle_file))
+
+    print(f"Read {len(data)} files in total.")
+    print()
+
+    return data
diff --git a/grimoiressg/modules/__init__.py b/grimoiressg/modules/__init__.py
new file mode 100644
index 0000000..1240793
--- /dev/null
+++ b/grimoiressg/modules/__init__.py
@@ -0,0 +1,9 @@
+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
+}
diff --git a/grimoiressg/modules/markdown.py b/grimoiressg/modules/markdown.py
new file mode 100644
index 0000000..8bbfca4
--- /dev/null
+++ b/grimoiressg/modules/markdown.py
@@ -0,0 +1,8 @@
+import markdown
+
+
+def compile_markdown(data, context):
+    for entry in data:
+        if "markdown" in entry:
+            print(f"Compiling markdown for {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..053f88c
--- /dev/null
+++ b/grimoiressg/modules/tags.py
@@ -0,0 +1,18 @@
+
+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:
+        print("Found tags:")
+        for tag in tags.keys():
+            print(f" - {tag} ({len(tags[tag])} files)")
+    else:
+        print("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..f7c875b
--- /dev/null
+++ b/grimoiressg/modules/templating.py
@@ -0,0 +1,36 @@
+import os
+
+from grimoiressg.utils import to_relative
+
+from jinja2 import Environment, FileSystemLoader
+
+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)
+            print(f"Rendering template for {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"])
+            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"])
+
+    print(f"{files_written} rendered")
diff --git a/grimoiressg/utils/__init__.py b/grimoiressg/utils/__init__.py
new file mode 100644
index 0000000..8205bd0
--- /dev/null
+++ b/grimoiressg/utils/__init__.py
@@ -0,0 +1 @@
+from .files import to_relative
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/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"

From 7ad0d3eb409b54cbb52a3ae7fd7491d3c5afe651 Mon Sep 17 00:00:00 2001
From: sigmasternchen <git@sigma-star.io>
Date: Tue, 7 Jan 2025 20:30:09 +0100
Subject: [PATCH 3/7] feat: Allow loading of external modules

---
 example/config.yml               | 8 ++++++++
 external_module_test/__init__.py | 8 ++++++++
 grimoiressg/config.py            | 8 +++++++-
 grimoiressg/modules/__init__.py  | 4 ++++
 4 files changed, 27 insertions(+), 1 deletion(-)
 create mode 100644 example/config.yml
 create mode 100644 external_module_test/__init__.py

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..39d0ccc
--- /dev/null
+++ b/external_module_test/__init__.py
@@ -0,0 +1,8 @@
+from grimoiressg.modules import available_modules
+
+
+def test(data, context):
+    print("This is test module.")
+
+
+available_modules["test"] = test
diff --git a/grimoiressg/config.py b/grimoiressg/config.py
index ada4269..512fade 100644
--- a/grimoiressg/config.py
+++ b/grimoiressg/config.py
@@ -1,7 +1,7 @@
 import yaml
 from yaml import Loader
 
-from grimoiressg.modules import available_modules
+from grimoiressg.modules import available_modules, load_external_module
 
 
 def default_config():
@@ -21,9 +21,15 @@ def read_config(context):
         print("No config file given; using default config")
         config = default_config()
     else:
+        print("Loading config file...")
         with open(config_file, "r") as file:
             config = yaml.load(file, Loader) or {}
 
+    for module in config.get("load_modules", []):
+        print(f" Loading external module {module}")
+        load_external_module(module)
+    print()
+
     print("Enabled modules:")
     for module in config.get("enabled_modules", []):
         print(f" - {module}")
diff --git a/grimoiressg/modules/__init__.py b/grimoiressg/modules/__init__.py
index 1240793..133e55f 100644
--- a/grimoiressg/modules/__init__.py
+++ b/grimoiressg/modules/__init__.py
@@ -7,3 +7,7 @@ available_modules = {
     "markdown": compile_markdown,
     "templating": render_templates
 }
+
+
+def load_external_module(module):
+    __import__(module)

From 49ad983da07d5ad0d20c0245f78b8ab7a4ee3212 Mon Sep 17 00:00:00 2001
From: sigmasternchen <git@sigma-star.io>
Date: Tue, 7 Jan 2025 20:38:14 +0100
Subject: [PATCH 4/7] docs: Update README to include information about the
 plugin system

---
 README.md | 48 ++++++++++++++++++++++++++++++++++++++++++++----
 1 file changed, 44 insertions(+), 4 deletions(-)

diff --git a/README.md b/README.md
index a564fb8..d6e8c44 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:
     <h2>My latest blog articles:</h2>
     <ul>
     {% for entry in tags["blog"] %}
-        <li><a href="{{ entry.output }}">{{ entry.title }}</a> ({{ entry.date }})</li>
+        <li>
+            <a href="{{ entry.output }}">
+                {{ entry.title }}
+            </a> ({{ entry.date }})
+        </li>
     {% endfor %}
     </ul>
 {% endblock %}
@@ -126,7 +131,42 @@ 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
+
+
+def test(data, context):
+    print("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
 

From d7ef7a122a9068d76316a773ae6c73182b9f7dba Mon Sep 17 00:00:00 2001
From: Sigma <git@sigma-star.io>
Date: Fri, 10 Jan 2025 18:28:01 +0100
Subject: [PATCH 5/7] fix: Change utils import to named import

Co-authored-by: Ninaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa <nina@l1f.de>
---
 grimoiressg/utils/__init__.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/grimoiressg/utils/__init__.py b/grimoiressg/utils/__init__.py
index 8205bd0..96766ce 100644
--- a/grimoiressg/utils/__init__.py
+++ b/grimoiressg/utils/__init__.py
@@ -1 +1 @@
-from .files import to_relative
+from .files import to_relative as to_relative

From 201617312318966e3577c1a485383bd980a5d968 Mon Sep 17 00:00:00 2001
From: sigmasternchen <git@sigma-star.io>
Date: Fri, 10 Jan 2025 18:28:56 +0100
Subject: [PATCH 6/7] format: Change to consistent use of double-quotes

---
 grimoiressg/arguments.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/grimoiressg/arguments.py b/grimoiressg/arguments.py
index 22dd6b8..86d74b7 100644
--- a/grimoiressg/arguments.py
+++ b/grimoiressg/arguments.py
@@ -9,7 +9,7 @@ def parse_arguments_to_initial_context():
             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("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")
 

From f3f2889e44179110c883af5967e8576f23600afd Mon Sep 17 00:00:00 2001
From: sigmasternchen <git@sigma-star.io>
Date: Fri, 10 Jan 2025 18:53:57 +0100
Subject: [PATCH 7/7] feat: Switch to Python logging

---
 README.md                         |  3 ++-
 external_module_test/__init__.py  |  3 ++-
 grimoiressg/__main__.py           |  9 ++++++---
 grimoiressg/arguments.py          | 11 ++++++-----
 grimoiressg/config.py             | 18 ++++++++++--------
 grimoiressg/content_files.py      |  9 ++++-----
 grimoiressg/modules/markdown.py   |  4 +++-
 grimoiressg/modules/tags.py       |  8 +++++---
 grimoiressg/modules/templating.py | 10 +++++-----
 grimoiressg/utils/__init__.py     |  3 ++-
 grimoiressg/utils/logger.py       |  5 +++++
 11 files changed, 50 insertions(+), 33 deletions(-)
 create mode 100644 grimoiressg/utils/logger.py

diff --git a/README.md b/README.md
index d6e8c44..ed9eb2e 100644
--- a/README.md
+++ b/README.md
@@ -143,10 +143,11 @@ that modifies the list of available modules:
 
 ```Python
 from grimoiressg.modules import available_modules
+from grimoiressg.utils import logger
 
 
 def test(data, context):
-    print("This is test module.")
+    logger.info("This is test module.")
 
 
 available_modules["test"] = test
diff --git a/external_module_test/__init__.py b/external_module_test/__init__.py
index 39d0ccc..5a59c42 100644
--- a/external_module_test/__init__.py
+++ b/external_module_test/__init__.py
@@ -1,8 +1,9 @@
 from grimoiressg.modules import available_modules
+from grimoiressg.utils import logger
 
 
 def test(data, context):
-    print("This is test module.")
+    logger.info("This is test module.")
 
 
 available_modules["test"] = test
diff --git a/grimoiressg/__main__.py b/grimoiressg/__main__.py
index 0b4004e..b8b38cb 100644
--- a/grimoiressg/__main__.py
+++ b/grimoiressg/__main__.py
@@ -1,14 +1,16 @@
+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", []):
-        print(f"Applying module {module}...")
+        logger.info("Applying module %s...", module)
         available_modules[module](data, context)
-        print("")
 
 
 def main():
@@ -18,7 +20,8 @@ def main():
     data = recursively_read_files(context)
     apply_modules(data, config, context)
 
-    print("Done.")
+    logger.info("Done.")
+    logging.shutdown()
 
 
 if __name__ == "__main__":
diff --git a/grimoiressg/arguments.py b/grimoiressg/arguments.py
index 86d74b7..a062299 100644
--- a/grimoiressg/arguments.py
+++ b/grimoiressg/arguments.py
@@ -1,5 +1,7 @@
 import argparse
 
+from grimoiressg.utils import logger
+
 
 def parse_arguments_to_initial_context():
     parser = argparse.ArgumentParser(
@@ -21,11 +23,10 @@ def parse_arguments_to_initial_context():
         "filenames": args.content_file
     }
 
-    print(f"Output directory: {context['output_dir']}")
-    print(f"Config file: {context['config_file']}")
-    print("Content files:")
+    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"]:
-        print(f" - {filename}")
-    print()
+        logger.debug(" - %s", filename)
 
     return context
diff --git a/grimoiressg/config.py b/grimoiressg/config.py
index 512fade..ce4ebe4 100644
--- a/grimoiressg/config.py
+++ b/grimoiressg/config.py
@@ -1,7 +1,10 @@
+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():
@@ -18,24 +21,23 @@ def read_config(context):
     config_file = context.get("config_file", None)
 
     if not config_file:
-        print("No config file given; using default config")
+        logger.info("No config file given; using default config")
         config = default_config()
     else:
-        print("Loading config file...")
+        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", []):
-        print(f" Loading external module {module}")
+        logger.debug(" Loading external module %s", module)
         load_external_module(module)
-    print()
 
-    print("Enabled modules:")
+    logger.debug("Enabled modules:")
     for module in config.get("enabled_modules", []):
-        print(f" - {module}")
+        logger.debug(" - %s", module)
         if module not in available_modules:
-            print(f"    ERROR: Module does not exist")
+            logger.critical("Module does not exist: %s", module)
+            logging.shutdown()
             exit(1)
-    print()
 
     return config
diff --git a/grimoiressg/content_files.py b/grimoiressg/content_files.py
index 0c3f299..854d6af 100644
--- a/grimoiressg/content_files.py
+++ b/grimoiressg/content_files.py
@@ -3,11 +3,11 @@ import os
 import yaml
 from yaml import Loader
 
-from grimoiressg.utils.files import for_each_glob, to_relative
+from grimoiressg.utils import logger, for_each_glob, to_relative
 
 
 def handle_file(filename):
-    print(f" Reading {to_relative(filename)}...")
+    logger.debug(" Reading %s...", to_relative(filename))
 
     with open(filename, "r") as file:
         data = yaml.load(file, Loader)
@@ -28,12 +28,11 @@ def handle_file(filename):
 def recursively_read_files(context):
     data = []
 
-    print("Reading content files...")
+    logger.info("Reading content files...")
 
     for filename in context["filenames"]:
         data.extend(for_each_glob(filename, handle_file))
 
-    print(f"Read {len(data)} files in total.")
-    print()
+    logger.info(f"Read %d files in total.", len(data))
 
     return data
diff --git a/grimoiressg/modules/markdown.py b/grimoiressg/modules/markdown.py
index 8bbfca4..2202277 100644
--- a/grimoiressg/modules/markdown.py
+++ b/grimoiressg/modules/markdown.py
@@ -1,8 +1,10 @@
 import markdown
 
+from grimoiressg.utils import logger
+
 
 def compile_markdown(data, context):
     for entry in data:
         if "markdown" in entry:
-            print(f"Compiling markdown for {entry['relative_filename']}...")
+            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
index 053f88c..1d63cf7 100644
--- a/grimoiressg/modules/tags.py
+++ b/grimoiressg/modules/tags.py
@@ -1,3 +1,5 @@
+from grimoiressg.utils import logger
+
 
 def extract_tags(data, context):
     tags = {}
@@ -9,10 +11,10 @@ def extract_tags(data, context):
             tags[tag] = entry_list
 
     if tags:
-        print("Found tags:")
+        logger.debug("Found tags:")
         for tag in tags.keys():
-            print(f" - {tag} ({len(tags[tag])} files)")
+            logger.debug(" - %s (%d files)", tag, len(tags[tag]))
     else:
-        print("No tags found.")
+        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
index f7c875b..ef2e307 100644
--- a/grimoiressg/modules/templating.py
+++ b/grimoiressg/modules/templating.py
@@ -1,9 +1,9 @@
 import os
 
-from grimoiressg.utils import to_relative
-
 from jinja2 import Environment, FileSystemLoader
 
+from grimoiressg.utils import to_relative, logger
+
 jinja_env = Environment(
     loader=FileSystemLoader("/")
 )
@@ -16,7 +16,7 @@ def render_templates(data, context):
         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']}...")
+            logger.debug("Rendering template for %s...", entry['relative_filename'])
             template = jinja_env.get_template(template_path)
             entry["rendered"] = template.render(
                 **context,
@@ -28,9 +28,9 @@ def render_templates(data, context):
         if "rendered" in entry and "output" in entry:
             files_written += 1
             filename = os.path.realpath(context["output_dir"] + "/" + entry["output"])
-            print(f" writing to {to_relative(filename)}")
+            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"])
 
-    print(f"{files_written} rendered")
+    logger.debug("%d rendered", files_written)
diff --git a/grimoiressg/utils/__init__.py b/grimoiressg/utils/__init__.py
index 96766ce..c90e260 100644
--- a/grimoiressg/utils/__init__.py
+++ b/grimoiressg/utils/__init__.py
@@ -1 +1,2 @@
-from .files import to_relative as to_relative
+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/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")