Skip to content

Commit 75a575a

Browse files
committed
feat: add unified GitHub Pages site template
Build-time Jinja2 template system that generates GitHub Pages sites for tool repos by reading plugin.json, skills/, rules/, and mcp-tools.json. Self-hosts Inter and JetBrains Mono fonts. Made-with: Cursor
1 parent 944f655 commit 75a575a

File tree

7 files changed

+729
-0
lines changed

7 files changed

+729
-0
lines changed

site-template/build_site.py

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
#!/usr/bin/env python3
2+
"""Build a GitHub Pages site from plugin metadata and a Jinja2 template.
3+
4+
Reads .cursor-plugin/plugin.json, site.json, skills/, rules/, and
5+
mcp-tools.json from a tool repository and renders a single-page site.
6+
"""
7+
8+
import argparse
9+
import json
10+
import re
11+
import shutil
12+
import sys
13+
from pathlib import Path
14+
15+
from jinja2 import Environment, FileSystemLoader
16+
17+
18+
def load_json(path: Path) -> dict | list:
19+
with open(path, encoding="utf-8") as f:
20+
return json.load(f)
21+
22+
23+
def parse_skills(repo_root: Path) -> list[dict]:
24+
skills_dir = repo_root / "skills"
25+
if not skills_dir.is_dir():
26+
return []
27+
28+
results = []
29+
for skill_dir in sorted(skills_dir.iterdir()):
30+
skill_file = skill_dir / "SKILL.md"
31+
if not skill_file.is_file():
32+
continue
33+
34+
text = skill_file.read_text(encoding="utf-8", errors="replace")
35+
lines = text.strip().splitlines()
36+
37+
name = skill_dir.name.replace("-", " ").replace("_", " ").title()
38+
description = ""
39+
category = ""
40+
41+
heading_seen = False
42+
for line in lines:
43+
stripped = line.strip()
44+
if stripped.startswith("#"):
45+
heading_seen = True
46+
heading_text = re.sub(r"^#+\s*", "", stripped)
47+
if heading_text:
48+
name = heading_text
49+
continue
50+
if heading_seen and stripped and not description:
51+
description = stripped[:200]
52+
break
53+
if not heading_seen and stripped and not description:
54+
description = stripped[:200]
55+
56+
results.append({
57+
"name": name,
58+
"description": description,
59+
"category": skill_dir.name,
60+
})
61+
62+
return results
63+
64+
65+
def parse_rules(repo_root: Path) -> list[dict]:
66+
rules_dir = repo_root / "rules"
67+
if not rules_dir.is_dir():
68+
return []
69+
70+
results = []
71+
for rule_file in sorted(rules_dir.iterdir()):
72+
if rule_file.suffix not in (".mdc", ".md"):
73+
continue
74+
75+
text = rule_file.read_text(encoding="utf-8", errors="replace")
76+
lines = text.strip().splitlines()
77+
78+
name = rule_file.stem.replace("-", " ").replace("_", " ").title()
79+
description = ""
80+
scope = ""
81+
82+
for line in lines:
83+
stripped = line.strip()
84+
if stripped.startswith("---"):
85+
continue
86+
if ":" in stripped and not description:
87+
key, _, val = stripped.partition(":")
88+
key_lower = key.strip().lower()
89+
if key_lower == "description":
90+
description = val.strip()[:200]
91+
elif key_lower in ("globs", "scope"):
92+
scope = val.strip()
93+
continue
94+
if stripped and not description:
95+
description = stripped[:200]
96+
97+
results.append({
98+
"name": name,
99+
"description": description,
100+
"scope": scope,
101+
})
102+
103+
return results
104+
105+
106+
def load_mcp_tools(repo_root: Path) -> list[dict]:
107+
mcp_file = repo_root / "mcp-tools.json"
108+
if not mcp_file.is_file():
109+
return []
110+
data = load_json(mcp_file)
111+
if isinstance(data, list):
112+
return data
113+
return []
114+
115+
116+
def group_by_category(items: list[dict]) -> dict[str, list[dict]]:
117+
groups: dict[str, list[dict]] = {}
118+
for item in items:
119+
cat = item.get("category", "General") or "General"
120+
groups.setdefault(cat, []).append(item)
121+
return dict(sorted(groups.items()))
122+
123+
124+
def main():
125+
parser = argparse.ArgumentParser(description=__doc__)
126+
parser.add_argument(
127+
"--repo-root",
128+
type=Path,
129+
required=True,
130+
help="Path to the checked-out tool repository",
131+
)
132+
parser.add_argument(
133+
"--out",
134+
type=Path,
135+
default=Path("docs"),
136+
help="Output directory (default: docs)",
137+
)
138+
args = parser.parse_args()
139+
140+
repo_root = args.repo_root.resolve()
141+
out_dir = args.out.resolve()
142+
template_dir = Path(__file__).parent.resolve()
143+
144+
plugin_path = repo_root / ".cursor-plugin" / "plugin.json"
145+
if not plugin_path.is_file():
146+
print(f"ERROR: {plugin_path} not found", file=sys.stderr)
147+
sys.exit(1)
148+
149+
site_path = repo_root / "site.json"
150+
if not site_path.is_file():
151+
print(f"ERROR: {site_path} not found", file=sys.stderr)
152+
sys.exit(1)
153+
154+
plugin = load_json(plugin_path)
155+
site = load_json(site_path)
156+
157+
skills = parse_skills(repo_root)
158+
rules = parse_rules(repo_root)
159+
mcp_tools = load_mcp_tools(repo_root)
160+
mcp_grouped = group_by_category(mcp_tools)
161+
162+
context = {
163+
"plugin": plugin,
164+
"site": site,
165+
"skills": skills,
166+
"skill_count": len(skills),
167+
"rules": rules,
168+
"rule_count": len(rules),
169+
"mcp_tools": mcp_tools,
170+
"mcp_tool_count": len(mcp_tools),
171+
"mcp_grouped": mcp_grouped,
172+
}
173+
174+
env = Environment(
175+
loader=FileSystemLoader(str(template_dir)),
176+
autoescape=True,
177+
keep_trailing_newline=True,
178+
)
179+
template = env.get_template("template.html.j2")
180+
html = template.render(**context)
181+
182+
out_dir.mkdir(parents=True, exist_ok=True)
183+
(out_dir / "index.html").write_text(html, encoding="utf-8")
184+
print(f"Wrote {out_dir / 'index.html'}")
185+
186+
fonts_src = template_dir / "fonts"
187+
fonts_dst = out_dir / "fonts"
188+
if fonts_src.is_dir():
189+
if fonts_dst.exists():
190+
shutil.rmtree(fonts_dst)
191+
shutil.copytree(fonts_src, fonts_dst)
192+
print(f"Copied fonts to {fonts_dst}")
193+
194+
assets_src = repo_root / "assets"
195+
assets_dst = out_dir / "assets"
196+
if assets_src.is_dir():
197+
if assets_dst.exists():
198+
shutil.rmtree(assets_dst)
199+
shutil.copytree(assets_src, assets_dst)
200+
print(f"Copied assets to {assets_dst}")
201+
202+
print("Done.")
203+
204+
205+
if __name__ == "__main__":
206+
main()
112 KB
Binary file not shown.
112 KB
Binary file not shown.
109 KB
Binary file not shown.
90.2 KB
Binary file not shown.

site-template/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Jinja2>=3.1,<4.0

0 commit comments

Comments
 (0)