Initial commit
This commit is contained in:
476
skills/documentation-update/doc_generator.py
Executable file
476
skills/documentation-update/doc_generator.py
Executable file
@@ -0,0 +1,476 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Documentation Generator
|
||||
|
||||
Generates documentation files from marketplace data using Jinja2 templates.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Any, Optional
|
||||
import argparse
|
||||
|
||||
# Try to use real Jinja2 if available, otherwise use SimpleTemplate fallback
|
||||
try:
|
||||
from jinja2 import Template as Jinja2Template
|
||||
USE_JINJA2 = True
|
||||
except ImportError:
|
||||
USE_JINJA2 = False
|
||||
|
||||
|
||||
class SimpleTemplate:
|
||||
"""Minimal Jinja2-like template engine"""
|
||||
|
||||
def __init__(self, template_str: str):
|
||||
self.template = template_str
|
||||
|
||||
def apply_filter(self, value: Any, filter_name: str) -> Any:
|
||||
"""Apply a filter to a value"""
|
||||
if filter_name == 'title':
|
||||
return str(value).replace('-', ' ').replace('_', ' ').title()
|
||||
elif filter_name == 'length':
|
||||
return len(value) if hasattr(value, '__len__') else 0
|
||||
elif filter_name.startswith('join'):
|
||||
# Extract separator from filter (e.g., "join(', ')")
|
||||
match = re.search(r"join\(['\"]([^'\"]*)['\"]\)", filter_name)
|
||||
if match and isinstance(value, list):
|
||||
separator = match.group(1)
|
||||
return separator.join(str(v) for v in value)
|
||||
return str(value)
|
||||
return value
|
||||
|
||||
def resolve_value(self, expr: str, context: Dict[str, Any]) -> Any:
|
||||
"""Resolve a variable expression with optional filters"""
|
||||
# Split expression and filters
|
||||
parts = expr.strip().split('|')
|
||||
var_expr = parts[0].strip()
|
||||
filters = [f.strip() for f in parts[1:]]
|
||||
|
||||
# Resolve the base variable
|
||||
value = context
|
||||
for key in var_expr.split('.'):
|
||||
key = key.strip()
|
||||
if isinstance(value, dict):
|
||||
value = value.get(key, '')
|
||||
elif hasattr(value, key):
|
||||
value = getattr(value, key)
|
||||
else:
|
||||
value = ''
|
||||
break
|
||||
|
||||
# Apply filters
|
||||
for filter_name in filters:
|
||||
value = self.apply_filter(value, filter_name)
|
||||
|
||||
return value
|
||||
|
||||
def render(self, context: Dict[str, Any]) -> str:
|
||||
"""Render template with context"""
|
||||
result = self.template
|
||||
|
||||
# Handle nested loops with .items(): {% for key, value in dict.items() %}...{% endfor %}
|
||||
items_pattern = r'{%\s*for\s+(\w+)\s*,\s*(\w+)\s+in\s+([\w.]+)\.items\(\)\s*%}(.*?){%\s*endfor\s*%}'
|
||||
|
||||
def replace_items_loop(match):
|
||||
key_var = match.group(1)
|
||||
value_var = match.group(2)
|
||||
dict_name = match.group(3)
|
||||
loop_body = match.group(4)
|
||||
|
||||
dict_obj = self.resolve_value(dict_name, context)
|
||||
if not isinstance(dict_obj, dict):
|
||||
return ""
|
||||
|
||||
output = []
|
||||
for key, value in dict_obj.items():
|
||||
loop_context = context.copy()
|
||||
loop_context[key_var] = key
|
||||
loop_context[value_var] = value
|
||||
|
||||
# Recursively render the loop body
|
||||
template = SimpleTemplate(loop_body)
|
||||
body_result = template.render(loop_context)
|
||||
output.append(body_result)
|
||||
|
||||
return "".join(output)
|
||||
|
||||
result = re.sub(items_pattern, replace_items_loop, result, flags=re.DOTALL)
|
||||
|
||||
# Handle loops with .keys(): {% for key in dict.keys() %}...{% endfor %}
|
||||
keys_pattern = r'{%\s*for\s+(\w+)\s+in\s+([\w.]+)\.keys\(\)\s*%}(.*?){%\s*endfor\s*%}'
|
||||
|
||||
def replace_keys_loop(match):
|
||||
var_name = match.group(1)
|
||||
dict_name = match.group(2)
|
||||
loop_body = match.group(3)
|
||||
|
||||
dict_obj = self.resolve_value(dict_name, context)
|
||||
if not isinstance(dict_obj, dict):
|
||||
return ""
|
||||
|
||||
output = []
|
||||
for key in dict_obj.keys():
|
||||
loop_context = context.copy()
|
||||
loop_context[var_name] = key
|
||||
|
||||
# Recursively render the loop body
|
||||
template = SimpleTemplate(loop_body)
|
||||
body_result = template.render(loop_context)
|
||||
output.append(body_result)
|
||||
|
||||
return "".join(output)
|
||||
|
||||
result = re.sub(keys_pattern, replace_keys_loop, result, flags=re.DOTALL)
|
||||
|
||||
# Handle regular loops: {% for item in items %}...{% endfor %}
|
||||
for_pattern = r'{%\s*for\s+(\w+)\s+in\s+([\w.]+)\s*%}(.*?){%\s*endfor\s*%}'
|
||||
|
||||
def replace_for(match):
|
||||
var_name = match.group(1)
|
||||
list_name = match.group(2)
|
||||
loop_body = match.group(3)
|
||||
|
||||
items = self.resolve_value(list_name, context)
|
||||
if not isinstance(items, (list, dict)):
|
||||
return ""
|
||||
|
||||
# Handle both lists and dict values
|
||||
if isinstance(items, dict):
|
||||
items = list(items.values())
|
||||
|
||||
output = []
|
||||
for item in items:
|
||||
loop_context = context.copy()
|
||||
loop_context[var_name] = item
|
||||
|
||||
# If item is a dict, also add its keys directly to context for easier access
|
||||
if isinstance(item, dict):
|
||||
for key, value in item.items():
|
||||
loop_context[f"{var_name}.{key}"] = value
|
||||
|
||||
# Recursively render the loop body
|
||||
template = SimpleTemplate(loop_body)
|
||||
body_result = template.render(loop_context)
|
||||
output.append(body_result)
|
||||
|
||||
return "".join(output)
|
||||
|
||||
result = re.sub(for_pattern, replace_for, result, flags=re.DOTALL)
|
||||
|
||||
# Handle conditionals with comparison: {% if var1 == var2 %}...{% endif %}
|
||||
if_compare_pattern = r'{%\s*if\s+([\w.]+)\s*==\s*([\w.]+)\s*%}(.*?){%\s*endif\s*%}'
|
||||
|
||||
def replace_if_compare(match):
|
||||
left_expr = match.group(1)
|
||||
right_expr = match.group(2)
|
||||
body = match.group(3)
|
||||
|
||||
left_val = self.resolve_value(left_expr, context)
|
||||
right_val = self.resolve_value(right_expr, context)
|
||||
|
||||
if left_val == right_val:
|
||||
template = SimpleTemplate(body)
|
||||
return template.render(context)
|
||||
return ""
|
||||
|
||||
result = re.sub(if_compare_pattern, replace_if_compare, result, flags=re.DOTALL)
|
||||
|
||||
# Handle conditionals with else: {% if condition %}...{% else %}...{% endif %}
|
||||
if_else_pattern = r'{%\s*if\s+([\w.]+)\s*%}(.*?){%\s*else\s*%}(.*?){%\s*endif\s*%}'
|
||||
|
||||
def replace_if_else(match):
|
||||
condition = match.group(1)
|
||||
true_body = match.group(2)
|
||||
false_body = match.group(3)
|
||||
|
||||
cond_val = self.resolve_value(condition, context)
|
||||
|
||||
if cond_val:
|
||||
template = SimpleTemplate(true_body)
|
||||
return template.render(context)
|
||||
else:
|
||||
template = SimpleTemplate(false_body)
|
||||
return template.render(context)
|
||||
|
||||
result = re.sub(if_else_pattern, replace_if_else, result, flags=re.DOTALL)
|
||||
|
||||
# Handle simple conditionals: {% if condition %}...{% endif %}
|
||||
if_pattern = r'{%\s*if\s+([\w.]+)\s*%}(.*?){%\s*endif\s*%}'
|
||||
|
||||
def replace_if(match):
|
||||
condition = match.group(1)
|
||||
body = match.group(2)
|
||||
|
||||
cond_val = self.resolve_value(condition, context)
|
||||
|
||||
if cond_val:
|
||||
template = SimpleTemplate(body)
|
||||
return template.render(context)
|
||||
return ""
|
||||
|
||||
result = re.sub(if_pattern, replace_if, result, flags=re.DOTALL)
|
||||
|
||||
# Replace variables with filters: {{ variable|filter }}
|
||||
var_pattern = r'{{\s*([\w.|()\'",\s]+)\s*}}'
|
||||
|
||||
def replace_var(match):
|
||||
expr = match.group(1)
|
||||
value = self.resolve_value(expr, context)
|
||||
return str(value) if value is not None else ''
|
||||
|
||||
result = re.sub(var_pattern, replace_var, result)
|
||||
|
||||
# Clean up any remaining template syntax
|
||||
result = re.sub(r'{%.*?%}', '', result)
|
||||
result = re.sub(r'{{.*?}}', '', result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class DocGenerator:
|
||||
"""Generates documentation from marketplace data"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
marketplace_path: str = ".claude-plugin/marketplace.json",
|
||||
templates_dir: str = "plugins/claude-plugin/skills/documentation-update/assets",
|
||||
output_dir: str = "docs",
|
||||
):
|
||||
self.marketplace_path = Path(marketplace_path)
|
||||
self.templates_dir = Path(templates_dir)
|
||||
self.output_dir = Path(output_dir)
|
||||
self.marketplace_data: Dict[str, Any] = {}
|
||||
|
||||
def load_marketplace(self) -> None:
|
||||
"""Load marketplace.json"""
|
||||
if not self.marketplace_path.exists():
|
||||
raise FileNotFoundError(f"Marketplace not found: {self.marketplace_path}")
|
||||
|
||||
with open(self.marketplace_path, 'r') as f:
|
||||
self.marketplace_data = json.load(f)
|
||||
|
||||
def extract_frontmatter(self, file_path: Path) -> Dict[str, str]:
|
||||
"""Extract YAML frontmatter from a markdown file"""
|
||||
if not file_path.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Match frontmatter between --- delimiters
|
||||
match = re.match(r'^---\s*\n(.*?)\n---\s*\n', content, re.DOTALL)
|
||||
if not match:
|
||||
return {}
|
||||
|
||||
frontmatter_text = match.group(1)
|
||||
frontmatter = {}
|
||||
|
||||
# Simple YAML parsing (key: value)
|
||||
for line in frontmatter_text.split('\n'):
|
||||
if ':' in line:
|
||||
key, value = line.split(':', 1)
|
||||
frontmatter[key.strip()] = value.strip().strip('"\'')
|
||||
|
||||
return frontmatter
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not parse frontmatter in {file_path}: {e}")
|
||||
return {}
|
||||
|
||||
def build_context(self) -> Dict[str, Any]:
|
||||
"""Build template context from marketplace data"""
|
||||
context = {
|
||||
"marketplace": self.marketplace_data,
|
||||
"now": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"plugins_by_category": {},
|
||||
"all_agents": [],
|
||||
"all_skills": [],
|
||||
"all_commands": [],
|
||||
"stats": {
|
||||
"total_plugins": 0,
|
||||
"total_agents": 0,
|
||||
"total_commands": 0,
|
||||
"total_skills": 0,
|
||||
},
|
||||
}
|
||||
|
||||
if "plugins" not in self.marketplace_data:
|
||||
return context
|
||||
|
||||
plugins = self.marketplace_data["plugins"]
|
||||
context["stats"]["total_plugins"] = len(plugins)
|
||||
|
||||
# Organize plugins by category
|
||||
for plugin in plugins:
|
||||
category = plugin.get("category", "general")
|
||||
if category not in context["plugins_by_category"]:
|
||||
context["plugins_by_category"][category] = []
|
||||
context["plugins_by_category"][category].append(plugin)
|
||||
|
||||
plugin_name = plugin.get("name", "")
|
||||
plugin_dir = Path(f"plugins/{plugin_name}")
|
||||
|
||||
# Extract agent information
|
||||
if "agents" in plugin:
|
||||
for agent_path in plugin["agents"]:
|
||||
agent_file = agent_path.replace("./agents/", "")
|
||||
full_path = plugin_dir / agent_path.lstrip('./')
|
||||
frontmatter = self.extract_frontmatter(full_path)
|
||||
|
||||
context["all_agents"].append({
|
||||
"plugin": plugin_name,
|
||||
"name": frontmatter.get("name", agent_file.replace(".md", "")),
|
||||
"file": agent_file,
|
||||
"description": frontmatter.get("description", ""),
|
||||
"model": frontmatter.get("model", ""),
|
||||
})
|
||||
|
||||
context["stats"]["total_agents"] += len(plugin["agents"])
|
||||
|
||||
# Extract command information
|
||||
if "commands" in plugin:
|
||||
for cmd_path in plugin["commands"]:
|
||||
cmd_file = cmd_path.replace("./commands/", "")
|
||||
full_path = plugin_dir / cmd_path.lstrip('./')
|
||||
frontmatter = self.extract_frontmatter(full_path)
|
||||
|
||||
context["all_commands"].append({
|
||||
"plugin": plugin_name,
|
||||
"name": frontmatter.get("name", cmd_file.replace(".md", "")),
|
||||
"file": cmd_file,
|
||||
"description": frontmatter.get("description", ""),
|
||||
})
|
||||
|
||||
context["stats"]["total_commands"] += len(plugin["commands"])
|
||||
|
||||
# Extract skill information
|
||||
if "skills" in plugin:
|
||||
for skill_path in plugin["skills"]:
|
||||
skill_name = skill_path.replace("./skills/", "")
|
||||
full_path = plugin_dir / skill_path.lstrip('./') / "SKILL.md"
|
||||
frontmatter = self.extract_frontmatter(full_path)
|
||||
|
||||
context["all_skills"].append({
|
||||
"plugin": plugin_name,
|
||||
"name": frontmatter.get("name", skill_name),
|
||||
"path": skill_name,
|
||||
"description": frontmatter.get("description", ""),
|
||||
})
|
||||
|
||||
context["stats"]["total_skills"] += len(plugin["skills"])
|
||||
|
||||
return context
|
||||
|
||||
def render_template(self, template_name: str, context: Dict[str, Any]) -> str:
|
||||
"""Render a template with context"""
|
||||
template_path = self.templates_dir / f"{template_name}.md.j2"
|
||||
|
||||
if not template_path.exists():
|
||||
raise FileNotFoundError(f"Template not found: {template_path}")
|
||||
|
||||
with open(template_path, 'r') as f:
|
||||
template_content = f.read()
|
||||
|
||||
if USE_JINJA2:
|
||||
# Use real Jinja2 for full compatibility
|
||||
template = Jinja2Template(template_content)
|
||||
return template.render(**context)
|
||||
else:
|
||||
# Fallback to SimpleTemplate
|
||||
template = SimpleTemplate(template_content)
|
||||
return template.render(context)
|
||||
|
||||
def generate_all(self, dry_run: bool = False, specific_file: Optional[str] = None) -> None:
|
||||
"""Generate all documentation files"""
|
||||
self.load_marketplace()
|
||||
context = self.build_context()
|
||||
|
||||
docs_to_generate = {
|
||||
"agents": "agents.md",
|
||||
"agent-skills": "agent-skills.md",
|
||||
"plugins": "plugins.md",
|
||||
"usage": "usage.md",
|
||||
}
|
||||
|
||||
if specific_file:
|
||||
if specific_file not in docs_to_generate:
|
||||
raise ValueError(f"Unknown documentation file: {specific_file}")
|
||||
docs_to_generate = {specific_file: docs_to_generate[specific_file]}
|
||||
|
||||
for template_name, output_file in docs_to_generate.items():
|
||||
try:
|
||||
print(f"Generating {output_file}...")
|
||||
content = self.render_template(template_name, context)
|
||||
|
||||
if dry_run:
|
||||
print(f"\n--- {output_file} ---")
|
||||
print(content[:500] + "..." if len(content) > 500 else content)
|
||||
print()
|
||||
else:
|
||||
output_path = self.output_dir / output_file
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(output_path, 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
print(f"✓ Generated {output_path}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error generating {output_file}: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point"""
|
||||
parser = argparse.ArgumentParser(description="Generate documentation from marketplace")
|
||||
parser.add_argument(
|
||||
"--marketplace",
|
||||
default=".claude-plugin/marketplace.json",
|
||||
help="Path to marketplace.json",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--templates",
|
||||
default="plugins/claude-plugin/skills/documentation-update/assets",
|
||||
help="Path to templates directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
default="docs",
|
||||
help="Output directory for documentation",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--file",
|
||||
choices=["agents", "agent-skills", "plugins", "usage"],
|
||||
help="Generate specific file only",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Show output without writing files",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
generator = DocGenerator(
|
||||
marketplace_path=args.marketplace,
|
||||
templates_dir=args.templates,
|
||||
output_dir=args.output,
|
||||
)
|
||||
|
||||
generator.generate_all(dry_run=args.dry_run, specific_file=args.file)
|
||||
|
||||
if not args.dry_run:
|
||||
print("\n✓ Documentation generation complete")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user