Merge pull request #1 from sigmasternchen/feat/modules

feat: Modules & Plugin System
This commit is contained in:
Sigma 2025-01-10 19:12:55 +01:00 committed by GitHub
commit adc2b98064
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 309 additions and 137 deletions

View file

@ -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. - **Markdown Support**: Write content in Markdown, which is automatically converted to HTML.
- **Tagging System**: Organize your content with tags for easy referencing in templates. - **Tagging System**: Organize your content with tags for easy referencing in templates.
- **File Inclusion**: Include other YAML files to create a modular content structure. - **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 ## 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. You can specify an output directory using the `-o` or `--output` flag.
```bash ```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 ### Alternative Installation
@ -43,7 +44,7 @@ poetry install
You can then run the program directly using Poetry: You can then run the program directly using Poetry:
```bash ```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 ### Example YAML File
@ -101,7 +102,11 @@ extends a layout and includes dynamic content:
<h2>My latest blog articles:</h2> <h2>My latest blog articles:</h2>
<ul> <ul>
{% for entry in tags["blog"] %} {% 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 %} {% endfor %}
</ul> </ul>
{% endblock %} {% endblock %}
@ -126,7 +131,43 @@ Additionally, the following fields are defined:
### Output Structure ### 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 ## Contributing
@ -134,7 +175,7 @@ Contributions are welcome! If you have suggestions or improvements, feel free to
## License ## 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 ## Acknowledgments

8
example/config.yml Normal file
View file

@ -0,0 +1,8 @@
load_modules:
- external_module_test
enabled_modules:
- tags
- markdown
- templating
- test

View file

@ -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

View file

@ -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()

28
grimoiressg/__main__.py Normal file
View file

@ -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()

32
grimoiressg/arguments.py Normal file
View file

@ -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

43
grimoiressg/config.py Normal file
View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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"])

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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")

View file

@ -1,7 +1,7 @@
[tool.poetry] [tool.poetry]
name = "grimoire-ssg" name = "grimoire-ssg"
packages = [ packages = [
{ include = "grimoire-ssg" } { include = "grimoiressg" }
] ]
version = "0.1.0" version = "0.1.0"
description = "A minimalistic Static Site Generator" description = "A minimalistic Static Site Generator"