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 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")
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
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ stapler = stapler.cli:main
@@ -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>