stapler-ssg 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stapler/__init__.py +1 -0
- stapler/cli.py +72 -0
- stapler/config.py +122 -0
- stapler/core/__init__.py +0 -0
- stapler/core/engine.py +184 -0
- stapler/core/utils.py +83 -0
- stapler/plugins/__init__.py +0 -0
- stapler/plugins/blog.py +162 -0
- stapler/plugins/sitemap.py +55 -0
- stapler/server.py +139 -0
- stapler_ssg-0.1.2.dist-info/METADATA +284 -0
- stapler_ssg-0.1.2.dist-info/RECORD +15 -0
- stapler_ssg-0.1.2.dist-info/WHEEL +4 -0
- stapler_ssg-0.1.2.dist-info/entry_points.txt +2 -0
- stapler_ssg-0.1.2.dist-info/licenses/LICENSE +24 -0
stapler/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
stapler/cli.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from colorama import init
|
|
5
|
+
|
|
6
|
+
from .config import load_config
|
|
7
|
+
from .core.engine import build_site
|
|
8
|
+
from .server import serve
|
|
9
|
+
|
|
10
|
+
init()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main():
|
|
14
|
+
parser = argparse.ArgumentParser(
|
|
15
|
+
description="Stapler - A flexible static site generator",
|
|
16
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"command",
|
|
21
|
+
nargs="?",
|
|
22
|
+
default="build",
|
|
23
|
+
choices=["build", "serve"],
|
|
24
|
+
help="Command to run (default: build)",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"-c",
|
|
29
|
+
"--config",
|
|
30
|
+
default="stapler.toml",
|
|
31
|
+
help="Path to configuration file (default: stapler.toml)",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"-p",
|
|
36
|
+
"--port",
|
|
37
|
+
type=int,
|
|
38
|
+
default=8000,
|
|
39
|
+
help="Port for development server (default: 8000)",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--version",
|
|
44
|
+
action="store_true",
|
|
45
|
+
help="Show version and exit",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
args = parser.parse_args()
|
|
49
|
+
|
|
50
|
+
if args.version:
|
|
51
|
+
from . import __version__
|
|
52
|
+
|
|
53
|
+
print(f"Stapler {__version__}")
|
|
54
|
+
sys.exit(0)
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
config = load_config(args.config)
|
|
58
|
+
except FileNotFoundError as e:
|
|
59
|
+
print(f"Error: {e}")
|
|
60
|
+
sys.exit(1)
|
|
61
|
+
except ValueError as e:
|
|
62
|
+
print(f"Configuration error: {e}")
|
|
63
|
+
sys.exit(1)
|
|
64
|
+
|
|
65
|
+
if args.command == "serve":
|
|
66
|
+
serve(config, args.port)
|
|
67
|
+
else:
|
|
68
|
+
build_site(config)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
main()
|
stapler/config.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tomllib
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def load_config(config_path="stapler.toml"):
|
|
9
|
+
path = Path(config_path)
|
|
10
|
+
|
|
11
|
+
if not path.exists():
|
|
12
|
+
raise FileNotFoundError(f"Configuration file not found: {path}")
|
|
13
|
+
|
|
14
|
+
if path.suffix == ".toml":
|
|
15
|
+
with open(path, "rb") as f:
|
|
16
|
+
config = tomllib.load(f)
|
|
17
|
+
elif path.suffix in [".yaml", ".yml"]:
|
|
18
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
19
|
+
config = yaml.safe_load(f) or {}
|
|
20
|
+
else:
|
|
21
|
+
raise ValueError(f"Unsupported config format: {path.suffix}. Use .toml or .yaml")
|
|
22
|
+
|
|
23
|
+
site = config.get("site", {})
|
|
24
|
+
if not site.get("url"):
|
|
25
|
+
raise ValueError("site.url is required in configuration")
|
|
26
|
+
if not site.get("title"):
|
|
27
|
+
raise ValueError("site.title is required in configuration")
|
|
28
|
+
|
|
29
|
+
return config
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_site_dir(config):
|
|
33
|
+
return config.get("directories", {}).get("site", "site")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_build_dir(config):
|
|
37
|
+
return config.get("directories", {}).get("build", "build")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_build_dev_dir(config):
|
|
41
|
+
return config.get("directories", {}).get("build_dev", "build-dev")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_templates_dir(config):
|
|
45
|
+
templates = config.get("directories", {}).get("templates", "templates")
|
|
46
|
+
return os.path.join(get_site_dir(config), templates)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_blog_dir(config):
|
|
50
|
+
if not has_blog(config):
|
|
51
|
+
return None
|
|
52
|
+
blog = config.get("directories", {}).get("blog", "blog")
|
|
53
|
+
return os.path.join(get_site_dir(config), blog)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def has_blog(config):
|
|
57
|
+
return config.get("features", {}).get("blog", {}).get("enabled", False)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def has_sitemap(config):
|
|
61
|
+
return config.get("features", {}).get("sitemap", True)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def has_feeds(config):
|
|
65
|
+
feeds_config = config.get("features", {}).get("feeds", True)
|
|
66
|
+
if isinstance(feeds_config, bool):
|
|
67
|
+
return feeds_config and has_blog(config)
|
|
68
|
+
return (feeds_config.get("rss", True) or feeds_config.get("atom", True)) and has_blog(config)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_feed_formats(config):
|
|
72
|
+
if not has_feeds(config):
|
|
73
|
+
return []
|
|
74
|
+
feeds_config = config.get("features", {}).get("feeds", True)
|
|
75
|
+
if isinstance(feeds_config, bool):
|
|
76
|
+
return ["rss", "atom"] if feeds_config else []
|
|
77
|
+
formats = []
|
|
78
|
+
if feeds_config.get("rss", True):
|
|
79
|
+
formats.append("rss")
|
|
80
|
+
if feeds_config.get("atom", True):
|
|
81
|
+
formats.append("atom")
|
|
82
|
+
return formats
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_base_path(config):
|
|
86
|
+
return config.get("site", {}).get("base_path", "")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_site_url(config):
|
|
90
|
+
return config["site"]["url"]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_site_title(config):
|
|
94
|
+
return config["site"]["title"]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_site_description(config):
|
|
98
|
+
return config.get("site", {}).get("description", "")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_author_name(config):
|
|
102
|
+
return config.get("site", {}).get("author", {}).get("name", "")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_author_email(config):
|
|
106
|
+
return config.get("site", {}).get("author", {}).get("email", "")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def get_markdown_extensions(config):
|
|
110
|
+
return config.get("markdown", {}).get("extensions", ["meta", "tables", "fenced_code"])
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def get_blog_template(config):
|
|
114
|
+
return config.get("features", {}).get("blog", {}).get("template", "blog_post.html")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_blog_index_template(config):
|
|
118
|
+
return config.get("features", {}).get("blog", {}).get("index_template", "blog_index.html")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_default_template(config):
|
|
122
|
+
return config.get("templates", {}).get("default", "base.html")
|
stapler/core/__init__.py
ADDED
|
File without changes
|
stapler/core/engine.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import tempfile
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from colorama import Fore, Style
|
|
7
|
+
from jinja2 import Environment, FileSystemLoader
|
|
8
|
+
from markdown import Markdown
|
|
9
|
+
|
|
10
|
+
from .. import config as cfg
|
|
11
|
+
from ..plugins import blog, sitemap
|
|
12
|
+
from .utils import get_data, infer_page_metadata, parse_front_matter, warn
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_site(config, output_dir=None, is_dev=False):
|
|
16
|
+
if output_dir is None:
|
|
17
|
+
output_dir = cfg.get_build_dev_dir(config) if is_dev else cfg.get_build_dir(config)
|
|
18
|
+
|
|
19
|
+
start_time = time.time()
|
|
20
|
+
print(f"{Fore.CYAN}=> Building site <={Style.RESET_ALL}")
|
|
21
|
+
|
|
22
|
+
print("> Setting up environment... ", end="", flush=True)
|
|
23
|
+
setup_start = time.time()
|
|
24
|
+
temp_build_dir = tempfile.mkdtemp()
|
|
25
|
+
|
|
26
|
+
loader_paths = [cfg.get_site_dir(config)]
|
|
27
|
+
templates_dir = cfg.get_templates_dir(config)
|
|
28
|
+
if os.path.exists(templates_dir):
|
|
29
|
+
loader_paths.append(templates_dir)
|
|
30
|
+
template_env = Environment(loader=FileSystemLoader(loader_paths))
|
|
31
|
+
|
|
32
|
+
md_processor = Markdown(extensions=cfg.get_markdown_extensions(config))
|
|
33
|
+
data = get_data()
|
|
34
|
+
|
|
35
|
+
setup_time = time.time() - setup_start
|
|
36
|
+
print(f"{Fore.GREEN}done ({setup_time * 1000:.0f}ms){Style.RESET_ALL}")
|
|
37
|
+
|
|
38
|
+
posts = []
|
|
39
|
+
if cfg.has_blog(config):
|
|
40
|
+
print("> Processing blog posts... ", end="", flush=True)
|
|
41
|
+
blog_start = time.time()
|
|
42
|
+
posts = blog.process_blog(config, template_env, md_processor, data, temp_build_dir)
|
|
43
|
+
blog_time = time.time() - blog_start
|
|
44
|
+
print(f"{Fore.GREEN}{len(posts)} posts ({blog_time * 1000:.0f}ms){Style.RESET_ALL}")
|
|
45
|
+
|
|
46
|
+
print("> Processing site files... ", end="", flush=True)
|
|
47
|
+
files_start = time.time()
|
|
48
|
+
_process_site_files(config, template_env, md_processor, data, temp_build_dir)
|
|
49
|
+
files_time = time.time() - files_start
|
|
50
|
+
print(f"{Fore.GREEN}done ({files_time * 1000:.0f}ms){Style.RESET_ALL}")
|
|
51
|
+
|
|
52
|
+
if cfg.has_sitemap(config):
|
|
53
|
+
print("> Generating sitemap... ", end="", flush=True)
|
|
54
|
+
sitemap_start = time.time()
|
|
55
|
+
sitemap.generate_sitemap(config, temp_build_dir, posts)
|
|
56
|
+
sitemap_time = time.time() - sitemap_start
|
|
57
|
+
print(f"{Fore.GREEN}done ({sitemap_time * 1000:.0f}ms){Style.RESET_ALL}")
|
|
58
|
+
|
|
59
|
+
print("> Finalizing build... ", end="", flush=True)
|
|
60
|
+
finalize_start = time.time()
|
|
61
|
+
if os.path.exists(output_dir):
|
|
62
|
+
shutil.rmtree(output_dir)
|
|
63
|
+
shutil.move(temp_build_dir, output_dir)
|
|
64
|
+
finalize_time = time.time() - finalize_start
|
|
65
|
+
print(f"{Fore.GREEN}done ({finalize_time * 1000:.0f}ms){Style.RESET_ALL}")
|
|
66
|
+
|
|
67
|
+
total_time = time.time() - start_time
|
|
68
|
+
print(f"{Fore.GREEN}Build complete in {total_time * 1000:.0f}ms!{Style.RESET_ALL}\n")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _process_site_files(config, template_env, md_processor, data, build_dir):
|
|
72
|
+
seen_outputs = {}
|
|
73
|
+
exclude_dirs = [cfg.get_templates_dir(config)]
|
|
74
|
+
blog_dir = cfg.get_blog_dir(config)
|
|
75
|
+
if blog_dir:
|
|
76
|
+
exclude_dirs.append(blog_dir)
|
|
77
|
+
|
|
78
|
+
site_dir = cfg.get_site_dir(config)
|
|
79
|
+
for root, dirs, files in os.walk(site_dir):
|
|
80
|
+
dirs[:] = [d for d in dirs if os.path.join(root, d) not in exclude_dirs]
|
|
81
|
+
|
|
82
|
+
for filename in files:
|
|
83
|
+
filepath = os.path.join(root, filename)
|
|
84
|
+
|
|
85
|
+
if any(filepath.startswith(excluded) for excluded in exclude_dirs):
|
|
86
|
+
continue
|
|
87
|
+
if filename.startswith("."):
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
rel_path = os.path.relpath(filepath, site_dir)
|
|
91
|
+
|
|
92
|
+
if rel_path.endswith(".md"):
|
|
93
|
+
output_path = os.path.join(build_dir, rel_path[:-3] + ".html")
|
|
94
|
+
else:
|
|
95
|
+
output_path = os.path.join(build_dir, rel_path)
|
|
96
|
+
|
|
97
|
+
if output_path in seen_outputs:
|
|
98
|
+
warn(f"Duplicate output: {output_path} (from {filepath} and {seen_outputs[output_path]})")
|
|
99
|
+
seen_outputs[output_path] = filepath
|
|
100
|
+
|
|
101
|
+
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
|
102
|
+
|
|
103
|
+
if filepath.endswith(".md"):
|
|
104
|
+
_process_markdown_file(config, template_env, md_processor, data, filepath, output_path, rel_path)
|
|
105
|
+
elif filepath.endswith(".html"):
|
|
106
|
+
_process_html_file(config, template_env, data, filepath, output_path, rel_path)
|
|
107
|
+
else:
|
|
108
|
+
shutil.copy2(filepath, output_path)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _process_markdown_file(config, template_env, md_processor, data, filepath, output_path, rel_path):
|
|
112
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
113
|
+
content = f.read()
|
|
114
|
+
|
|
115
|
+
metadata, markdown_content = parse_front_matter(content)
|
|
116
|
+
html_content = md_processor.convert(markdown_content)
|
|
117
|
+
md_processor.reset()
|
|
118
|
+
|
|
119
|
+
template_name = metadata.get("template")
|
|
120
|
+
if not template_name:
|
|
121
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
122
|
+
f.write(html_content)
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
active_page, canonical_path = infer_page_metadata(rel_path, cfg.get_base_path(config))
|
|
127
|
+
|
|
128
|
+
page_data = {"content": html_content}
|
|
129
|
+
page_data["metadata"] = metadata
|
|
130
|
+
if "active_page" not in page_data:
|
|
131
|
+
page_data["active_page"] = active_page
|
|
132
|
+
if "canonical_path" not in page_data:
|
|
133
|
+
page_data["canonical_path"] = canonical_path
|
|
134
|
+
|
|
135
|
+
template = template_env.get_template(template_name)
|
|
136
|
+
rendered = template.render(page=page_data, data=data)
|
|
137
|
+
|
|
138
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
139
|
+
f.write(rendered)
|
|
140
|
+
except Exception as e:
|
|
141
|
+
warn(f"Failed to render {filepath}: {e}")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _process_html_file(config, template_env, data, filepath, output_path, rel_path):
|
|
145
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
146
|
+
content = f.read()
|
|
147
|
+
|
|
148
|
+
metadata, html_content = parse_front_matter(content)
|
|
149
|
+
|
|
150
|
+
if metadata:
|
|
151
|
+
template_name = metadata.get("template", cfg.get_default_template(config))
|
|
152
|
+
try:
|
|
153
|
+
active_page, canonical_path = infer_page_metadata(rel_path, cfg.get_base_path(config))
|
|
154
|
+
|
|
155
|
+
page_data = {"content": html_content}
|
|
156
|
+
page_data["metadata"] = metadata
|
|
157
|
+
if "active_page" not in page_data:
|
|
158
|
+
page_data["active_page"] = active_page
|
|
159
|
+
if "canonical_path" not in page_data:
|
|
160
|
+
page_data["canonical_path"] = canonical_path
|
|
161
|
+
|
|
162
|
+
template = template_env.get_template(template_name)
|
|
163
|
+
rendered = template.render(page=page_data, data=data)
|
|
164
|
+
except Exception as e:
|
|
165
|
+
warn(f"Failed to render {filepath}: {e}")
|
|
166
|
+
return
|
|
167
|
+
else:
|
|
168
|
+
try:
|
|
169
|
+
active_page, canonical_path = infer_page_metadata(rel_path, cfg.get_base_path(config))
|
|
170
|
+
page_data = {
|
|
171
|
+
"active_page": active_page,
|
|
172
|
+
"canonical_path": canonical_path
|
|
173
|
+
}
|
|
174
|
+
template = template_env.from_string(content)
|
|
175
|
+
rendered = template.render(
|
|
176
|
+
page=page_data,
|
|
177
|
+
data=data,
|
|
178
|
+
)
|
|
179
|
+
except Exception as e:
|
|
180
|
+
warn(f"Failed to render {filepath}: {e}")
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
184
|
+
f.write(rendered)
|
stapler/core/utils.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import subprocess
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
from colorama import Fore, Style
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
FRONT_MATTER_PATTERN = re.compile(r"^---\n(.*?)\n---", re.DOTALL)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def warn(message):
|
|
14
|
+
print(f"{Fore.YELLOW}WARNING: {message}{Style.RESET_ALL}")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_front_matter(content):
|
|
18
|
+
match = FRONT_MATTER_PATTERN.match(content)
|
|
19
|
+
if match:
|
|
20
|
+
metadata = yaml.safe_load(match.group(1)) or {}
|
|
21
|
+
remaining = content.split("---", 2)[2].strip()
|
|
22
|
+
return metadata, remaining
|
|
23
|
+
return {}, content
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_git_commit_info():
|
|
27
|
+
try:
|
|
28
|
+
output = subprocess.check_output(
|
|
29
|
+
["git", "log", "-1", "--format=%h %H %ct"],
|
|
30
|
+
text=True,
|
|
31
|
+
stderr=subprocess.DEVNULL,
|
|
32
|
+
).strip()
|
|
33
|
+
if output:
|
|
34
|
+
parts = output.split()
|
|
35
|
+
if len(parts) == 3:
|
|
36
|
+
short_hash = parts[0]
|
|
37
|
+
long_hash = parts[1]
|
|
38
|
+
commit_ts = int(parts[2])
|
|
39
|
+
commit_dt = datetime.fromtimestamp(commit_ts, tz=timezone.utc)
|
|
40
|
+
return {
|
|
41
|
+
"hash": {"short": short_hash, "long": long_hash},
|
|
42
|
+
"dt": {
|
|
43
|
+
"date": {
|
|
44
|
+
"long": commit_dt.strftime("%B %d, %Y"),
|
|
45
|
+
"short": commit_dt.strftime("%Y-%m-%d"),
|
|
46
|
+
},
|
|
47
|
+
"time": commit_dt.strftime("%H:%M:%S"),
|
|
48
|
+
"iso": commit_dt.isoformat(),
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
|
|
52
|
+
pass
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_data():
|
|
57
|
+
now = datetime.now(timezone.utc)
|
|
58
|
+
return {
|
|
59
|
+
"last_commit": get_git_commit_info(),
|
|
60
|
+
"now": {
|
|
61
|
+
"date": {
|
|
62
|
+
"long": now.strftime("%B %d, %Y"),
|
|
63
|
+
"short": now.strftime("%Y-%m-%d"),
|
|
64
|
+
},
|
|
65
|
+
"time": now.strftime("%H:%M:%S"),
|
|
66
|
+
"iso": now.isoformat(),
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def infer_page_metadata(rel_path, base_path=""):
|
|
72
|
+
if rel_path == "index.html":
|
|
73
|
+
canonical_path = base_path if base_path else "/"
|
|
74
|
+
else:
|
|
75
|
+
path_without_ext = os.path.splitext(rel_path)[0]
|
|
76
|
+
canonical_path = f"{base_path}/{path_without_ext}" if base_path else f"/{path_without_ext}"
|
|
77
|
+
|
|
78
|
+
active_page = rel_path.split("/")[0].replace(".html", "").replace(".md", "")
|
|
79
|
+
|
|
80
|
+
if active_page == "index":
|
|
81
|
+
active_page = "home"
|
|
82
|
+
|
|
83
|
+
return active_page, canonical_path
|
|
File without changes
|
stapler/plugins/blog.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
from feedgen.feed import FeedGenerator
|
|
6
|
+
|
|
7
|
+
from .. import config as cfg
|
|
8
|
+
from ..core.utils import parse_front_matter, warn
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def process_blog(config, template_env, md_processor, data, build_dir):
|
|
12
|
+
posts = []
|
|
13
|
+
blog_slugs = set()
|
|
14
|
+
|
|
15
|
+
blog_dir = cfg.get_blog_dir(config)
|
|
16
|
+
if not os.path.exists(blog_dir):
|
|
17
|
+
return posts
|
|
18
|
+
|
|
19
|
+
for filename in os.listdir(blog_dir):
|
|
20
|
+
if not filename.endswith(".md"):
|
|
21
|
+
continue
|
|
22
|
+
|
|
23
|
+
slug = filename.replace(".md", "")
|
|
24
|
+
if slug in blog_slugs:
|
|
25
|
+
warn(f"Duplicate blog slug: {slug}")
|
|
26
|
+
blog_slugs.add(slug)
|
|
27
|
+
|
|
28
|
+
filepath = os.path.join(blog_dir, filename)
|
|
29
|
+
post = _process_post(config, md_processor, filepath, slug)
|
|
30
|
+
if post:
|
|
31
|
+
posts.append(post)
|
|
32
|
+
|
|
33
|
+
posts.sort(
|
|
34
|
+
key=lambda p: p.get("created") or datetime.min.replace(tzinfo=timezone.utc),
|
|
35
|
+
reverse=True,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
blog_section = os.path.basename(blog_dir)
|
|
39
|
+
blog_build_dir = os.path.join(build_dir, blog_section)
|
|
40
|
+
os.makedirs(blog_build_dir, exist_ok=True)
|
|
41
|
+
|
|
42
|
+
_generate_blog_index(config, template_env, data, blog_build_dir, blog_section, posts)
|
|
43
|
+
_generate_post_pages(config, template_env, data, blog_build_dir, blog_section, posts)
|
|
44
|
+
|
|
45
|
+
if cfg.has_feeds(config):
|
|
46
|
+
_generate_feeds(config, blog_build_dir, blog_section, posts)
|
|
47
|
+
|
|
48
|
+
return posts
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _process_post(config, md_processor, filepath, slug):
|
|
52
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
53
|
+
content = f.read()
|
|
54
|
+
|
|
55
|
+
metadata, markdown_content = parse_front_matter(content)
|
|
56
|
+
html_content = md_processor.convert(markdown_content)
|
|
57
|
+
md_processor.reset()
|
|
58
|
+
|
|
59
|
+
date_str = metadata.get("date")
|
|
60
|
+
if date_str:
|
|
61
|
+
if isinstance(date_str, str):
|
|
62
|
+
created_date = datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
|
63
|
+
else:
|
|
64
|
+
created_date = datetime.combine(date_str, datetime.min.time()).replace(tzinfo=timezone.utc)
|
|
65
|
+
else:
|
|
66
|
+
created_date = _get_git_date(filepath)
|
|
67
|
+
if not created_date:
|
|
68
|
+
warn(f"No date found for blog post: {os.path.basename(filepath)}")
|
|
69
|
+
|
|
70
|
+
post = {
|
|
71
|
+
"title": metadata.get("title", slug.replace("-", " ").title()),
|
|
72
|
+
"slug": slug,
|
|
73
|
+
"content": html_content,
|
|
74
|
+
"filepath": filepath,
|
|
75
|
+
"created": created_date,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if created_date:
|
|
79
|
+
post["date"] = created_date.strftime("%Y-%m-%d")
|
|
80
|
+
post["date_iso"] = created_date.isoformat()
|
|
81
|
+
|
|
82
|
+
return post
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _get_git_date(filepath):
|
|
86
|
+
try:
|
|
87
|
+
output = subprocess.check_output(
|
|
88
|
+
["git", "log", "--follow", "--format=%H %ct", "--", filepath],
|
|
89
|
+
text=True,
|
|
90
|
+
stderr=subprocess.DEVNULL,
|
|
91
|
+
).strip()
|
|
92
|
+
if output:
|
|
93
|
+
first_commit_ts = int(output.split("\n")[-1].split()[1])
|
|
94
|
+
return datetime.fromtimestamp(first_commit_ts, tz=timezone.utc)
|
|
95
|
+
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
|
|
96
|
+
pass
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _generate_blog_index(config, template_env, data, blog_dir, blog_section, posts):
|
|
101
|
+
base_path = cfg.get_base_path(config)
|
|
102
|
+
canonical_path = f"{base_path}/{blog_section}" if base_path else f"/{blog_section}"
|
|
103
|
+
|
|
104
|
+
with open(os.path.join(blog_dir, "index.html"), "w", encoding="utf-8") as f:
|
|
105
|
+
f.write(
|
|
106
|
+
template_env.get_template(cfg.get_blog_index_template(config)).render(
|
|
107
|
+
posts=posts,
|
|
108
|
+
page={"active_page": blog_section, "canonical_path": canonical_path},
|
|
109
|
+
data=data,
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _generate_post_pages(config, template_env, data, blog_dir, blog_section, posts):
|
|
115
|
+
base_path = cfg.get_base_path(config)
|
|
116
|
+
for post in posts:
|
|
117
|
+
canonical_path = f"{base_path}/{blog_section}/{post['slug']}" if base_path else f"/{blog_section}/{post['slug']}"
|
|
118
|
+
|
|
119
|
+
post_path = os.path.join(blog_dir, f"{post['slug']}.html")
|
|
120
|
+
with open(post_path, "w", encoding="utf-8") as f:
|
|
121
|
+
f.write(
|
|
122
|
+
template_env.get_template(cfg.get_blog_template(config)).render(
|
|
123
|
+
post=post,
|
|
124
|
+
page={"active_page": blog_section, "canonical_path": canonical_path},
|
|
125
|
+
data=data,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _generate_feeds(config, blog_dir, blog_section, posts):
|
|
131
|
+
fg = FeedGenerator()
|
|
132
|
+
fg.title(f"{cfg.get_site_title(config)} - {blog_section}")
|
|
133
|
+
fg.description(cfg.get_site_description(config))
|
|
134
|
+
fg.id(cfg.get_site_url(config))
|
|
135
|
+
fg.link(href=f"{cfg.get_site_url(config)}/{blog_section}", rel="alternate")
|
|
136
|
+
fg.language("en")
|
|
137
|
+
|
|
138
|
+
author_name = cfg.get_author_name(config)
|
|
139
|
+
if author_name:
|
|
140
|
+
fg.author(
|
|
141
|
+
name=author_name,
|
|
142
|
+
email=cfg.get_author_email(config) or None,
|
|
143
|
+
uri=cfg.get_site_url(config),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
for post in posts:
|
|
147
|
+
fe = fg.add_entry()
|
|
148
|
+
fe.title(post["title"])
|
|
149
|
+
fe.link(href=f"{cfg.get_site_url(config)}/{blog_section}/{post['slug']}")
|
|
150
|
+
fe.id(f"{cfg.get_site_url(config)}/{blog_section}/{post['slug']}")
|
|
151
|
+
fe.description(post["content"])
|
|
152
|
+
if post.get("created"):
|
|
153
|
+
fe.pubDate(post["created"])
|
|
154
|
+
fe.updated(post["created"])
|
|
155
|
+
|
|
156
|
+
formats = cfg.get_feed_formats(config)
|
|
157
|
+
if "rss" in formats:
|
|
158
|
+
with open(os.path.join(blog_dir, "rss.xml"), "wb") as f:
|
|
159
|
+
f.write(fg.rss_str(pretty=True))
|
|
160
|
+
if "atom" in formats:
|
|
161
|
+
with open(os.path.join(blog_dir, "atom.xml"), "wb") as f:
|
|
162
|
+
f.write(fg.atom_str(pretty=True))
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import xml.etree.ElementTree as ET
|
|
3
|
+
|
|
4
|
+
from .. import config as cfg
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def generate_sitemap(config, build_dir, posts):
|
|
8
|
+
urlset = ET.Element("urlset", xmlns="http://www.sitemaps.org/schemas/sitemap/0.9")
|
|
9
|
+
|
|
10
|
+
_add_url(urlset, f"{cfg.get_site_url(config)}/")
|
|
11
|
+
|
|
12
|
+
if cfg.has_blog(config) and posts:
|
|
13
|
+
blog_section = os.path.basename(cfg.get_blog_dir(config))
|
|
14
|
+
_add_url(urlset, f"{cfg.get_site_url(config)}/{blog_section}/")
|
|
15
|
+
|
|
16
|
+
for post in posts:
|
|
17
|
+
lastmod = post["created"].strftime("%Y-%m-%d") if post.get("created") else None
|
|
18
|
+
_add_url(
|
|
19
|
+
urlset,
|
|
20
|
+
f"{cfg.get_site_url(config)}/{blog_section}/{post['slug']}",
|
|
21
|
+
lastmod=lastmod,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
for root, _, files in os.walk(build_dir):
|
|
25
|
+
for filename in files:
|
|
26
|
+
if not filename.endswith(".html"):
|
|
27
|
+
continue
|
|
28
|
+
if filename in ["404.html", "index.html"]:
|
|
29
|
+
continue
|
|
30
|
+
|
|
31
|
+
filepath = os.path.join(root, filename)
|
|
32
|
+
rel_path = os.path.relpath(filepath, build_dir)
|
|
33
|
+
|
|
34
|
+
if cfg.has_blog(config):
|
|
35
|
+
blog_section = os.path.basename(cfg.get_blog_dir(config))
|
|
36
|
+
if rel_path.startswith(blog_section):
|
|
37
|
+
continue
|
|
38
|
+
|
|
39
|
+
url_path = "/" + rel_path.replace("\\", "/").replace(".html", "")
|
|
40
|
+
_add_url(urlset, f"{cfg.get_site_url(config)}{url_path}")
|
|
41
|
+
|
|
42
|
+
tree = ET.ElementTree(urlset)
|
|
43
|
+
ET.indent(tree, space=" ")
|
|
44
|
+
tree.write(
|
|
45
|
+
os.path.join(build_dir, "sitemap.xml"),
|
|
46
|
+
encoding="utf-8",
|
|
47
|
+
xml_declaration=True,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _add_url(urlset, loc, lastmod=None):
|
|
52
|
+
url = ET.SubElement(urlset, "url")
|
|
53
|
+
ET.SubElement(url, "loc").text = loc
|
|
54
|
+
if lastmod:
|
|
55
|
+
ET.SubElement(url, "lastmod").text = lastmod
|
stapler/server.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
|
7
|
+
|
|
8
|
+
from colorama import Fore, Style
|
|
9
|
+
from watchdog.events import FileSystemEventHandler
|
|
10
|
+
from watchdog.observers import Observer
|
|
11
|
+
|
|
12
|
+
from . import config as cfg
|
|
13
|
+
from .core.engine import build_site
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BuildHandler(FileSystemEventHandler):
|
|
17
|
+
def __init__(self, build_func):
|
|
18
|
+
self.build_func = build_func
|
|
19
|
+
self.last_build = 0
|
|
20
|
+
|
|
21
|
+
def on_modified(self, event):
|
|
22
|
+
if event.is_directory or "build" in event.src_path:
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
now = time.time()
|
|
26
|
+
if now - self.last_build < 1:
|
|
27
|
+
return
|
|
28
|
+
self.last_build = now
|
|
29
|
+
|
|
30
|
+
if os.path.basename(event.src_path) in ["stapler.toml", "stapler.yaml", "stapler.yml"]:
|
|
31
|
+
print(f"\n{Fore.YELLOW}Config changed! Restarting...{Style.RESET_ALL}\n")
|
|
32
|
+
os.execv(sys.executable, [sys.executable] + sys.argv)
|
|
33
|
+
|
|
34
|
+
rel_path = os.path.relpath(event.src_path)
|
|
35
|
+
timestamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
|
|
36
|
+
print(f"\n{Fore.BLUE}[{timestamp}]{Style.RESET_ALL} {Fore.YELLOW}File changed:{Style.RESET_ALL} {rel_path}\n")
|
|
37
|
+
self.build_func()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class StaplerHTTPServer(SimpleHTTPRequestHandler):
|
|
41
|
+
directory = "build"
|
|
42
|
+
|
|
43
|
+
def __init__(self, *args, **kwargs):
|
|
44
|
+
super().__init__(*args, directory=self.directory, **kwargs)
|
|
45
|
+
|
|
46
|
+
def log_message(self, format, *args):
|
|
47
|
+
request_line = args[0]
|
|
48
|
+
parts = request_line.split()
|
|
49
|
+
|
|
50
|
+
if len(parts) >= 2:
|
|
51
|
+
method = parts[0]
|
|
52
|
+
path = parts[1]
|
|
53
|
+
else:
|
|
54
|
+
method = ""
|
|
55
|
+
path = request_line
|
|
56
|
+
|
|
57
|
+
method_colors = {
|
|
58
|
+
"GET": Fore.CYAN,
|
|
59
|
+
"POST": Fore.YELLOW,
|
|
60
|
+
"PUT": Fore.MAGENTA,
|
|
61
|
+
"DELETE": Fore.RED,
|
|
62
|
+
}
|
|
63
|
+
method_color = method_colors.get(method, Fore.WHITE)
|
|
64
|
+
|
|
65
|
+
status = args[1] if len(args) > 1 else "000"
|
|
66
|
+
if status.startswith("2"):
|
|
67
|
+
status_color = Fore.GREEN
|
|
68
|
+
elif status.startswith("3"):
|
|
69
|
+
status_color = Fore.CYAN
|
|
70
|
+
elif status.startswith("4"):
|
|
71
|
+
status_color = Fore.YELLOW
|
|
72
|
+
else:
|
|
73
|
+
status_color = Fore.RED
|
|
74
|
+
|
|
75
|
+
timestamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
|
|
76
|
+
print(f"{Fore.BLUE}[{timestamp}]{Style.RESET_ALL} {method_color}{Style.BRIGHT}{method}{Style.RESET_ALL} {Fore.WHITE}{path}{Style.RESET_ALL} {status_color}{status}{Style.RESET_ALL}")
|
|
77
|
+
|
|
78
|
+
def do_GET(self):
|
|
79
|
+
path = self.translate_path(self.path)
|
|
80
|
+
|
|
81
|
+
if self.path.endswith("/") or self.path == "":
|
|
82
|
+
index_path = os.path.join(path, "index.html")
|
|
83
|
+
if os.path.isfile(index_path):
|
|
84
|
+
self.path = self.path.rstrip("/") + "/index.html" if not self.path.endswith("index.html") else self.path
|
|
85
|
+
return super().do_GET()
|
|
86
|
+
|
|
87
|
+
if os.path.isfile(path):
|
|
88
|
+
return super().do_GET()
|
|
89
|
+
|
|
90
|
+
if not self.path.endswith("/") and "." not in os.path.basename(self.path):
|
|
91
|
+
html_path = path + ".html"
|
|
92
|
+
if os.path.isfile(html_path):
|
|
93
|
+
self.path += ".html"
|
|
94
|
+
return super().do_GET()
|
|
95
|
+
|
|
96
|
+
not_found_path = os.path.join(self.directory, "404.html")
|
|
97
|
+
if os.path.isfile(not_found_path):
|
|
98
|
+
self.send_response(404)
|
|
99
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
100
|
+
self.end_headers()
|
|
101
|
+
with open(not_found_path, "rb") as f:
|
|
102
|
+
self.wfile.write(f.read())
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
self.send_error(404, "File not found")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def serve(config, port=8000):
|
|
109
|
+
print(f"{Fore.BLUE}=== Development Server ==={Style.RESET_ALL}\n")
|
|
110
|
+
|
|
111
|
+
build_site(config, is_dev=True)
|
|
112
|
+
|
|
113
|
+
observer = Observer()
|
|
114
|
+
handler = BuildHandler(lambda: build_site(config, is_dev=True))
|
|
115
|
+
observer.schedule(handler, cfg.get_site_dir(config), recursive=True)
|
|
116
|
+
observer.schedule(handler, ".", recursive=False)
|
|
117
|
+
observer.start()
|
|
118
|
+
|
|
119
|
+
build_dev_dir = cfg.get_build_dev_dir(config)
|
|
120
|
+
|
|
121
|
+
class DevHTTPServer(StaplerHTTPServer):
|
|
122
|
+
directory = build_dev_dir
|
|
123
|
+
|
|
124
|
+
server = HTTPServer(("localhost", port), DevHTTPServer)
|
|
125
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
126
|
+
|
|
127
|
+
print(f"{Fore.GREEN}Server running at {Style.BRIGHT}http://localhost:{port}{Style.RESET_ALL}")
|
|
128
|
+
print(f"{Fore.CYAN}Serving from: {Style.BRIGHT}{build_dev_dir}/{Style.RESET_ALL}")
|
|
129
|
+
print(f"{Fore.MAGENTA}Watching: {Style.BRIGHT}{cfg.get_site_dir(config)}/ {Style.RESET_ALL}and config file")
|
|
130
|
+
print(f"\n{Fore.YELLOW}Press Ctrl+C to stop{Style.RESET_ALL}\n")
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
while True:
|
|
134
|
+
time.sleep(1)
|
|
135
|
+
except KeyboardInterrupt:
|
|
136
|
+
print(f"\n{Fore.RED}Stopping server...{Style.RESET_ALL}")
|
|
137
|
+
observer.stop()
|
|
138
|
+
server.shutdown()
|
|
139
|
+
observer.join()
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stapler-ssg
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Simple Jinja-based static site generator
|
|
5
|
+
Project-URL: Homepage, https://github.com/gijs6/stapler
|
|
6
|
+
Project-URL: Repository, https://github.com/gijs6/stapler
|
|
7
|
+
Author-email: Gijs6 <me@gijs6.nl>
|
|
8
|
+
License: Unlicense
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Requires-Dist: colorama>=0.4.6
|
|
12
|
+
Requires-Dist: feedgen>=1.0.0
|
|
13
|
+
Requires-Dist: jinja2>=3.1.0
|
|
14
|
+
Requires-Dist: markdown>=3.5.0
|
|
15
|
+
Requires-Dist: pyyaml>=6.0
|
|
16
|
+
Requires-Dist: watchdog>=3.0.0
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# Stapler
|
|
22
|
+
|
|
23
|
+
A simple static site generator built with Jinja and Markdown.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
Clone the repo:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
git clone https://github.com/gijs6/stapler.git
|
|
31
|
+
cd stapler
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Create a virtual environment (recommended):
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
python -m venv .venv
|
|
38
|
+
source .venv/bin/activate
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Install:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install -e .
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Or with dev dependencies:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install -e ".[dev]"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Quick start
|
|
54
|
+
|
|
55
|
+
1. Create a `stapler.toml` in your project root:
|
|
56
|
+
|
|
57
|
+
```toml
|
|
58
|
+
[site]
|
|
59
|
+
url = "https://yoursite.com"
|
|
60
|
+
title = "Your site"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
2. Put your content in a `site/` directory (the default). Templates go in `site/templates/`.
|
|
64
|
+
|
|
65
|
+
3. Run:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
stapler serve # local dev server on port 8000
|
|
69
|
+
stapler build # production build
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Configuration
|
|
73
|
+
|
|
74
|
+
### Required
|
|
75
|
+
|
|
76
|
+
```toml
|
|
77
|
+
[site]
|
|
78
|
+
url = "https://yoursite.com"
|
|
79
|
+
title = "Your site"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Optional
|
|
83
|
+
|
|
84
|
+
#### Site metadata
|
|
85
|
+
|
|
86
|
+
```toml
|
|
87
|
+
[site]
|
|
88
|
+
description = "About your site"
|
|
89
|
+
base_path = "/blog" # Deploy to example.com/blog instead of the root
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
#### Author info
|
|
93
|
+
|
|
94
|
+
Used in RSS/Atom feeds.
|
|
95
|
+
|
|
96
|
+
```toml
|
|
97
|
+
[site.author]
|
|
98
|
+
name = "Your name"
|
|
99
|
+
email = "you@example.com"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
#### Directories
|
|
103
|
+
|
|
104
|
+
All paths are relative to where you run `stapler`.
|
|
105
|
+
|
|
106
|
+
```toml
|
|
107
|
+
[directories]
|
|
108
|
+
site = "site" # Content directory (default: "site")
|
|
109
|
+
build = "build" # Production output (default: "build")
|
|
110
|
+
build_dev = "build-dev" # Dev server output (default: "build-dev")
|
|
111
|
+
templates = "templates" # Templates folder inside the site directory (default: "templates")
|
|
112
|
+
blog = "blog" # Blog posts folder inside the site directory (default: "blog")
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
#### Default template
|
|
116
|
+
|
|
117
|
+
The template used for HTML pages that have front matter but no `template` field.
|
|
118
|
+
|
|
119
|
+
```toml
|
|
120
|
+
[templates]
|
|
121
|
+
default = "base.html"
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
#### Blog
|
|
125
|
+
|
|
126
|
+
```toml
|
|
127
|
+
[features.blog]
|
|
128
|
+
enabled = true # Enable blog functionality (default: false)
|
|
129
|
+
template = "blog_post.html" # Template for individual posts
|
|
130
|
+
index_template = "blog_index.html" # Template for the blog index page
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
#### Sitemap and feeds
|
|
134
|
+
|
|
135
|
+
```toml
|
|
136
|
+
[features]
|
|
137
|
+
sitemap = true # Generate sitemap.xml (default: true)
|
|
138
|
+
|
|
139
|
+
[features.feeds]
|
|
140
|
+
rss = true # Generate rss.xml (default: true)
|
|
141
|
+
atom = true # Generate atom.xml (default: true)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Feeds are only generated when the blog feature is enabled.
|
|
145
|
+
|
|
146
|
+
#### Markdown extensions
|
|
147
|
+
|
|
148
|
+
```toml
|
|
149
|
+
[markdown]
|
|
150
|
+
extensions = ["meta", "tables", "fenced_code"] # Python-Markdown extensions
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## How it works
|
|
154
|
+
|
|
155
|
+
### Pages
|
|
156
|
+
|
|
157
|
+
Any `.html` or `.md` file in your site directory (excluding the templates and blog folders) becomes a page.
|
|
158
|
+
|
|
159
|
+
#### Markdown files
|
|
160
|
+
|
|
161
|
+
Markdown files are always rendered to HTML. If the front matter includes a `template` field, the result is passed to that template as `page.content`. Without a `template` field (or without front matter entirely), the raw HTML is written directly.
|
|
162
|
+
|
|
163
|
+
```markdown
|
|
164
|
+
---
|
|
165
|
+
template: base.html
|
|
166
|
+
title: My page
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
# Content
|
|
170
|
+
|
|
171
|
+
Regular markdown here
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
#### HTML files
|
|
175
|
+
|
|
176
|
+
HTML files with front matter are rendered through a template. The `template` field in front matter takes precedence; if omitted, the default template from `[templates].default` is used.
|
|
177
|
+
|
|
178
|
+
```html
|
|
179
|
+
---
|
|
180
|
+
title: My page
|
|
181
|
+
---
|
|
182
|
+
<h1>Content here</h1>
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
HTML files without front matter are treated as Jinja templates directly:
|
|
186
|
+
|
|
187
|
+
```html
|
|
188
|
+
{% extends "base.html" %}
|
|
189
|
+
{% block content %}
|
|
190
|
+
<h1>Hello</h1>
|
|
191
|
+
{% endblock %}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Front matter is YAML. All fields are available as `page.metadata.<field>` in your templates.
|
|
195
|
+
|
|
196
|
+
#### Static files
|
|
197
|
+
|
|
198
|
+
Anything that's not a `.html` or `.md` file (and not in your templates or blog folder) is copied as-is to the output directory.
|
|
199
|
+
|
|
200
|
+
### Blog
|
|
201
|
+
|
|
202
|
+
Enable the blog feature in your config, then put `.md` files in your blog directory.
|
|
203
|
+
|
|
204
|
+
```markdown
|
|
205
|
+
---
|
|
206
|
+
title: My post
|
|
207
|
+
date: 2025-01-15
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
Post content here
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
The `date` field is optional. If omitted, stapler tries to infer it from the file's git history.
|
|
214
|
+
|
|
215
|
+
### Templates
|
|
216
|
+
|
|
217
|
+
Templates live in the directory you configured (default: `site/templates/`).
|
|
218
|
+
|
|
219
|
+
#### Available in all templates
|
|
220
|
+
|
|
221
|
+
- `data`: build info
|
|
222
|
+
- `data.now`: current build time
|
|
223
|
+
- `data.now.date.long`: date as `%B %d, %Y` (e.g. `April 12, 2026`)
|
|
224
|
+
- `data.now.date.short`: date as `%Y-%m-%d` (e.g. `2026-04-12`)
|
|
225
|
+
- `data.now.time`: time as `%H:%M:%S`
|
|
226
|
+
- `data.now.iso`: datetime as ISO 8601
|
|
227
|
+
- `data.last_commit`: last git commit info (`None` if not in a git repo)
|
|
228
|
+
- `data.last_commit.hash.short`: short 7-character commit hash
|
|
229
|
+
- `data.last_commit.hash.long`: full commit hash
|
|
230
|
+
- `data.last_commit.dt.date.long`: date as `%B %d, %Y` (e.g. `April 12, 2026`)
|
|
231
|
+
- `data.last_commit.dt.date.short`: date as `%Y-%m-%d` (e.g. `2026-04-12`)
|
|
232
|
+
- `data.last_commit.dt.time`: time as `%H:%M:%S`
|
|
233
|
+
- `data.last_commit.dt.iso`: datetime as ISO 8601
|
|
234
|
+
|
|
235
|
+
#### Regular page templates
|
|
236
|
+
|
|
237
|
+
- `page`: the current page
|
|
238
|
+
- `page.active_page`: identifier derived from the filename (e.g. `about` for `about.html`, `home` for `index.html`), useful for highlighting the active nav item
|
|
239
|
+
- `page.canonical_path`: URL path of the page (e.g. `/about`)
|
|
240
|
+
- `page.content`: page content as HTML (only present if the page has front matter)
|
|
241
|
+
- `page.metadata.<field>`: any front matter field (e.g. `page.metadata.title`)
|
|
242
|
+
|
|
243
|
+
#### Blog post template
|
|
244
|
+
|
|
245
|
+
- `page`: navigation info
|
|
246
|
+
- `page.active_page`: name of the blog directory (e.g. `blog`)
|
|
247
|
+
- `page.canonical_path`: URL path of the post (e.g. `/blog/my-post`)
|
|
248
|
+
- `post`: the current blog post
|
|
249
|
+
- `post.title`: post title (from front matter, or derived from the filename)
|
|
250
|
+
- `post.slug`: URL slug (filename without `.md`)
|
|
251
|
+
- `post.content`: post content as HTML
|
|
252
|
+
- `post.date`: date as `%Y-%m-%d` (e.g. `2026-04-12`), only set if a date is available
|
|
253
|
+
- `post.date_iso`: date as ISO 8601, only set if a date is available
|
|
254
|
+
- `data`: same as above
|
|
255
|
+
|
|
256
|
+
#### Blog index template
|
|
257
|
+
|
|
258
|
+
- `page`: navigation info
|
|
259
|
+
- `page.active_page`: name of the blog directory (e.g. `blog`)
|
|
260
|
+
- `page.canonical_path`: URL path of the blog index (e.g. `/blog`)
|
|
261
|
+
- `posts`: list of all blog posts sorted newest first; each item has the same fields as `post` above
|
|
262
|
+
- `data`: same as above
|
|
263
|
+
|
|
264
|
+
## CLI
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
# Build with default config (stapler.toml)
|
|
268
|
+
stapler build
|
|
269
|
+
|
|
270
|
+
# Build with custom config
|
|
271
|
+
stapler build -c myconfig.toml
|
|
272
|
+
|
|
273
|
+
# Serve on default port (8000)
|
|
274
|
+
stapler serve
|
|
275
|
+
|
|
276
|
+
# Serve on custom port
|
|
277
|
+
stapler serve -p 3000
|
|
278
|
+
|
|
279
|
+
# Serve with custom config and port
|
|
280
|
+
stapler serve -c myconfig.toml -p 3000
|
|
281
|
+
|
|
282
|
+
# Show version
|
|
283
|
+
stapler --version
|
|
284
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
stapler/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
stapler/cli.py,sha256=kML5JdfwhedTtOIT95eJvednRxlWZXU6ydKYmLXlVNU,1490
|
|
3
|
+
stapler/config.py,sha256=bJ-3ot_F6f4dxL9T3lo10kc8OCJD7JHNI98V-Z0fuyU,3338
|
|
4
|
+
stapler/server.py,sha256=079BaTG4QyIuRNkU3X3pafoQorkh_YggUILqf4KpvL0,4848
|
|
5
|
+
stapler/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
stapler/core/engine.py,sha256=LLLPydOf4xQ4amoXSRI8jtj9U8z5cTOe9tLdV0aWPF0,6993
|
|
7
|
+
stapler/core/utils.py,sha256=hwx3ioPfN2nAI0PzIylvKAFZIqTG_NZ07nLgpquC-rU,2485
|
|
8
|
+
stapler/plugins/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
stapler/plugins/blog.py,sha256=UvIkGuyTlWQ-ML0zZI6gB_iiyUwFP03UMoAeUprKddI,5550
|
|
10
|
+
stapler/plugins/sitemap.py,sha256=i5YOIpxi756W3clMc7z2uQPbIMBGO4NsEwY0_Pnjhz0,1808
|
|
11
|
+
stapler_ssg-0.1.2.dist-info/METADATA,sha256=_6bNImOQXOa6zeoP3e2Sr27Oi7XqS2iKWXPtBR7sRkA,7094
|
|
12
|
+
stapler_ssg-0.1.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
13
|
+
stapler_ssg-0.1.2.dist-info/entry_points.txt,sha256=TsUoOHKvki6YXJpsMcCQwoTrhwMu7bWYv874FCKRgrY,45
|
|
14
|
+
stapler_ssg-0.1.2.dist-info/licenses/LICENSE,sha256=awOCsWJ58m_2kBQwBUGWejVqZm6wuRtCL2hi9rfa0X4,1211
|
|
15
|
+
stapler_ssg-0.1.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
This is free and unencumbered software released into the public domain.
|
|
2
|
+
|
|
3
|
+
Anyone is free to copy, modify, publish, use, compile, sell, or
|
|
4
|
+
distribute this software, either in source code form or as a compiled
|
|
5
|
+
binary, for any purpose, commercial or non-commercial, and by any
|
|
6
|
+
means.
|
|
7
|
+
|
|
8
|
+
In jurisdictions that recognize copyright laws, the author or authors
|
|
9
|
+
of this software dedicate any and all copyright interest in the
|
|
10
|
+
software to the public domain. We make this dedication for the benefit
|
|
11
|
+
of the public at large and to the detriment of our heirs and
|
|
12
|
+
successors. We intend this dedication to be an overt act of
|
|
13
|
+
relinquishment in perpetuity of all present and future rights to this
|
|
14
|
+
software under copyright law.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
19
|
+
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
|
20
|
+
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
|
21
|
+
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
22
|
+
OTHER DEALINGS IN THE SOFTWARE.
|
|
23
|
+
|
|
24
|
+
For more information, please refer to <https://unlicense.org>
|