template.py 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  1. from pathlib import Path
  2. from typing import Any, Dict, Set, Tuple
  3. from jinja2 import Environment, BaseLoader, meta, nodes
  4. import frontmatter
  5. class Template:
  6. """Data class for template information extracted from frontmatter."""
  7. def __init__(self, file_path: Path, frontmatter_data: Dict[str, Any], content: str):
  8. self.file_path = file_path
  9. self.content = content
  10. # Extract frontmatter fields with defaults
  11. self.name = frontmatter_data.get('name', file_path.parent.name) # Use directory name as default
  12. self.description = frontmatter_data.get('description', 'No description available')
  13. self.author = frontmatter_data.get('author', '')
  14. self.date = frontmatter_data.get('date', '')
  15. self.version = frontmatter_data.get('version', '')
  16. self.module = frontmatter_data.get('module', '')
  17. self.tags = frontmatter_data.get('tags', [])
  18. self.files = frontmatter_data.get('files', [])
  19. # Additional computed properties
  20. self.id = file_path.parent.name # Unique identifier (parent directory name)
  21. self.directory = file_path.parent.name # Directory name where the template is located
  22. self.relative_path = file_path.name
  23. self.size = file_path.stat().st_size if file_path.exists() else 0
  24. # Extract variables and defaults from the template content
  25. # vars: Set[str] - All Jinja2 variable names found in template (e.g., {'app_name', 'port', 'debug'})
  26. # var_defaults: Dict[str, Any] - Default values from | default() filters (e.g., {'app_name': 'my-app', 'port': 8080})
  27. self.vars, self.var_defaults = self._parse_template_variables(content)
  28. @classmethod
  29. def from_file(cls, file_path: Path) -> "Template":
  30. """Create a Template instance from a file path."""
  31. try:
  32. frontmatter_data, content = cls._parse_frontmatter(file_path)
  33. return cls(file_path=file_path, frontmatter_data=frontmatter_data, content=content)
  34. except Exception:
  35. # If frontmatter parsing fails, create a basic Template object
  36. return cls(
  37. file_path=file_path,
  38. frontmatter_data={'name': file_path.parent.name},
  39. content=""
  40. )
  41. @staticmethod
  42. def _parse_frontmatter(file_path: Path) -> Tuple[Dict[str, Any], str]:
  43. """Parse frontmatter and content from a file."""
  44. with open(file_path, 'r', encoding='utf-8') as f:
  45. post = frontmatter.load(f)
  46. return post.metadata, post.content
  47. def _parse_template_variables(self, template_content: str) -> Tuple[Set[str], Dict[str, Any]]:
  48. """Parse Jinja2 template to extract variables and their default values.
  49. Analyzes template content to find:
  50. 1. All undeclared variables (using AST analysis)
  51. 2. Default values from | default() filters (using AST traversal)
  52. Examples:
  53. {{ app_name | default('my-app') }} → vars={'app_name'}, defaults={'app_name': 'my-app'}
  54. {{ port | default(8080) }} → vars={'port'}, defaults={'port': 8080}
  55. {{ unused_var }} → vars={'unused_var'}, defaults={}
  56. Returns:
  57. Tuple of (all_variable_names, variable_defaults)
  58. """
  59. try:
  60. # Use consistent Jinja2 environment configuration
  61. env = Environment(
  62. loader=BaseLoader(),
  63. trim_blocks=True, # Remove first newline after block tags
  64. lstrip_blocks=True, # Strip leading whitespace from block tags
  65. keep_trailing_newline=False # Remove trailing newlines
  66. )
  67. ast = env.parse(template_content)
  68. # Extract all undeclared variables
  69. all_variables = meta.find_undeclared_variables(ast)
  70. # Extract default values from | default() filters
  71. defaults = {
  72. node.node.name: node.args[0].value
  73. for node in ast.find_all(nodes.Filter)
  74. if node.name == 'default'
  75. and isinstance(node.node, nodes.Name)
  76. and node.args
  77. and isinstance(node.args[0], nodes.Const)
  78. }
  79. return all_variables, defaults
  80. except Exception:
  81. return set(), {}
  82. @staticmethod
  83. def _parse_frontmatter(file_path: Path) -> Tuple[Dict[str, Any], str]:
  84. """Parse frontmatter and content from a file."""
  85. with open(file_path, 'r', encoding='utf-8') as f:
  86. post = frontmatter.load(f)
  87. return post.metadata, post.content
  88. def to_dict(self) -> Dict[str, Any]:
  89. """Convert to dictionary for display."""
  90. return {
  91. 'id': self.id,
  92. 'name': self.name,
  93. 'description': self.description,
  94. 'author': self.author,
  95. 'date': self.date,
  96. 'version': self.version,
  97. 'module': self.module,
  98. 'tags': self.tags,
  99. 'files': self.files,
  100. 'directory': self.directory,
  101. 'path': str(self.relative_path),
  102. 'size': f"{self.size:,} bytes",
  103. 'vars': list(self.vars),
  104. 'var_defaults': self.var_defaults
  105. }
  106. def render(self, variable_values: Dict[str, Any]) -> str:
  107. """Render the template with the provided variable values.
  108. Args:
  109. variable_values: Dictionary of variable names to their values
  110. Returns:
  111. Rendered template content as string
  112. """
  113. import logging
  114. import re
  115. logger = logging.getLogger('boilerplates')
  116. try:
  117. # Configure Jinja2 environment to handle whitespace and blank lines
  118. env = Environment(
  119. loader=BaseLoader(),
  120. trim_blocks=True, # Remove first newline after block tags
  121. lstrip_blocks=True, # Strip leading whitespace from block tags
  122. keep_trailing_newline=False # Remove trailing newlines
  123. )
  124. jinja_template = env.from_string(self.content)
  125. rendered_content = jinja_template.render(**variable_values)
  126. # Additional post-processing to remove multiple consecutive blank lines
  127. # Replace multiple consecutive newlines with single newlines
  128. rendered_content = re.sub(r'\n\s*\n\s*\n+', '\n\n', rendered_content)
  129. # Remove leading/trailing whitespace
  130. rendered_content = rendered_content.strip()
  131. return rendered_content
  132. except Exception as e:
  133. logger.error(f"Jinja2 template rendering failed: {e}")
  134. raise ValueError(f"Failed to render template: {e}")