mirror of
https://github.com/sigmasternchen/grimoire
synced 2025-03-15 08:08:55 +00:00
Merge pull request #1 from sigmasternchen/feat/modules
feat: Modules & Plugin System
This commit is contained in:
commit
adc2b98064
16 changed files with 309 additions and 137 deletions
51
README.md
51
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.
|
- **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
8
example/config.yml
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
load_modules:
|
||||||
|
- external_module_test
|
||||||
|
|
||||||
|
enabled_modules:
|
||||||
|
- tags
|
||||||
|
- markdown
|
||||||
|
- templating
|
||||||
|
- test
|
9
external_module_test/__init__.py
Normal file
9
external_module_test/__init__.py
Normal 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
|
|
@ -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
28
grimoiressg/__main__.py
Normal 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
32
grimoiressg/arguments.py
Normal 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
43
grimoiressg/config.py
Normal 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
|
38
grimoiressg/content_files.py
Normal file
38
grimoiressg/content_files.py
Normal 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
|
13
grimoiressg/modules/__init__.py
Normal file
13
grimoiressg/modules/__init__.py
Normal 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)
|
10
grimoiressg/modules/markdown.py
Normal file
10
grimoiressg/modules/markdown.py
Normal 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"])
|
20
grimoiressg/modules/tags.py
Normal file
20
grimoiressg/modules/tags.py
Normal 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
|
36
grimoiressg/modules/templating.py
Normal file
36
grimoiressg/modules/templating.py
Normal 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)
|
2
grimoiressg/utils/__init__.py
Normal file
2
grimoiressg/utils/__init__.py
Normal 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
|
18
grimoiressg/utils/files.py
Normal file
18
grimoiressg/utils/files.py
Normal 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
|
5
grimoiressg/utils/logger.py
Normal file
5
grimoiressg/utils/logger.py
Normal 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")
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in a new issue