""" HTML Generation Library Atomic functions for loading themes and rendering HTML from templates. """ import json from pathlib import Path from typing import Dict, List, Optional from datetime import datetime import jinja2 class html_generation_lib: """Atomic HTML generation functions""" @staticmethod def load_theme(theme_name: str, themes_dir: str = './themes') -> Dict: """ Load theme configuration and templates. Returns: Dict with theme config, template paths, and metadata """ theme_dir = Path(themes_dir) / theme_name theme_config_path = theme_dir / 'theme.json' if not theme_config_path.exists(): raise FileNotFoundError(f"Theme config not found: {theme_config_path}") with open(theme_config_path, 'r') as f: config = json.load(f) # Load template files templates = {} if 'templates' in config: for template_name, template_path in config['templates'].items(): full_path = Path(template_path) if full_path.exists(): with open(full_path, 'r') as f: templates[template_name] = f.read() config['loaded_templates'] = templates config['theme_dir'] = str(theme_dir) return config @staticmethod def render_template(template_string: str, data: Dict) -> str: """ Render template string with data using Jinja2 templating. Handles nested expressions and complex logic better. Args: template_string: Template with {{variable}} placeholders data: Dict of data to inject Returns: Rendered HTML string """ # Add helper functions to data context context = { **data, 'formatTime': html_generation_lib.format_time, 'formatTimeAgo': html_generation_lib.format_time_ago, 'formatDateTime': html_generation_lib.format_datetime, 'truncate': html_generation_lib.truncate, 'renderMarkdown': html_generation_lib.render_markdown, 'escapeHtml': html_generation_lib.escape_html } # Extract template content from ', template_string, re.DOTALL) if match: template_string = match.group(1) # Use Jinja2 for template rendering try: template = jinja2.Template(template_string) return template.render(**context) except Exception as e: print(f"Template rendering error: {e}") return f"" @staticmethod def render_post(post: Dict, theme: Dict, comments: Optional[List[Dict]] = None) -> str: """ Render single post to HTML using theme's post/card/detail template. Args: post: Post data dict theme: Theme config with loaded templates comments: Optional list of comments to render with post Returns: Rendered HTML string """ # Choose template (prefer 'detail' if comments, else 'card') template_name = 'detail' if comments else 'card' if template_name not in theme.get('loaded_templates', {}): template_name = 'card' # Fallback template = theme['loaded_templates'].get(template_name) if not template: return f"" # Render comments if provided comments_section = '' if comments: comments_section = html_generation_lib.render_comment_tree(comments, theme) # Create post data with comments_section post_data = dict(post) post_data['comments_section'] = comments_section # Render post return html_generation_lib.render_template(template, post_data) @staticmethod def render_post_page(post: Dict, theme: Dict, comments: Optional[List[Dict]] = None) -> str: """ Render single post as a complete HTML page with navigation. Args: post: Post data dict theme: Theme config with loaded templates comments: Optional list of comments to render with post Returns: Complete HTML page string """ # Render the post content post_content = html_generation_lib.render_post(post, theme, comments) # Build CSS links css_links = '' if theme.get('css_dependencies'): for css_path in theme['css_dependencies']: adjusted_path = css_path.replace('./themes/', '../../themes/') css_links += f' \n' # Build JS scripts js_scripts = '' if theme.get('js_dependencies'): for js_path in theme['js_dependencies']: adjusted_path = js_path.replace('./themes/', '../../themes/') js_scripts += f' \n' # Create full page page_html = f''' {post.get('title', 'Post')} - BalanceBoard {css_links}
{post_content}
{js_scripts} ''' return page_html @staticmethod def render_comment_tree(comments: List[Dict], theme: Dict, depth: int = 0) -> str: """ Recursively render nested comment tree (unlimited depth). Args: comments: List of comment dicts (may have 'children') theme: Theme config with loaded templates depth: Current nesting depth Returns: Rendered HTML string for all comments """ if not comments: return '' template = theme['loaded_templates'].get('comment') if not template: return '' html_parts = [] for comment in comments: # Recursively render children first children = comment.get('children', []) if children: children_html = html_generation_lib.render_comment_tree(children, theme, depth + 1) else: children_html = '' # Add depth and children_section to comment data comment_data = {**comment, 'depth': depth, 'children_section': children_html} # Render this comment comment_html = html_generation_lib.render_template(template, comment_data) html_parts.append(comment_html) return '\n'.join(html_parts) @staticmethod def render_index(posts: List[Dict], theme: Dict, filterset_name: str = '') -> str: """ Render index/list page with all posts. Args: posts: List of post dicts theme: Theme config with loaded templates filterset_name: Name of filterset used (for display) Returns: Complete HTML page """ template = theme['loaded_templates'].get('list') or theme['loaded_templates'].get('card') if not template: return '' # Render each post post_items = [] for post in posts: # Update post URL to use Flask route post_data = dict(post) post_data['post_url'] = f"/post/{post['uuid']}" post_html = html_generation_lib.render_template(template, post_data) post_items.append(post_html) # Create full page css_links = '' if theme.get('css_dependencies'): for css_path in theme['css_dependencies']: # Adjust relative paths to work from subdirectories (e.g., active_html/no_filter/) # Convert ./themes/... to ../../themes/... adjusted_path = css_path.replace('./themes/', '../../themes/') css_links += f' \n' js_scripts = '' if theme.get('js_dependencies'): for js_path in theme['js_dependencies']: # Adjust relative paths to work from subdirectories adjusted_path = js_path.replace('./themes/', '../../themes/') js_scripts += f' \n' page_html = f''' BalanceBoard - Content Feed {css_links}

{filterset_name.replace('_', ' ').title() if filterset_name else 'All Posts'}

{len(posts)} posts

{''.join(post_items)}
''' return page_html @staticmethod def write_html_file(html: str, output_path: str) -> None: """ Write HTML string to file. Args: html: HTML content output_path: File path to write to """ output_file = Path(output_path) output_file.parent.mkdir(parents=True, exist_ok=True) with open(output_file, 'w', encoding='utf-8') as f: f.write(html) # Helper functions for templates @staticmethod def format_time(timestamp: int) -> str: """Format timestamp as time""" dt = datetime.fromtimestamp(timestamp) return dt.strftime('%H:%M') @staticmethod def format_time_ago(timestamp: int) -> str: """Format timestamp as relative time (e.g., '2 hours ago')""" now = datetime.now() dt = datetime.fromtimestamp(timestamp) diff = now - dt seconds = diff.total_seconds() if seconds < 60: return 'just now' elif seconds < 3600: minutes = int(seconds / 60) return f'{minutes} minute{"s" if minutes != 1 else ""} ago' elif seconds < 86400: hours = int(seconds / 3600) return f'{hours} hour{"s" if hours != 1 else ""} ago' elif seconds < 604800: days = int(seconds / 86400) return f'{days} day{"s" if days != 1 else ""} ago' else: weeks = int(seconds / 604800) return f'{weeks} week{"s" if weeks != 1 else ""} ago' @staticmethod def format_datetime(timestamp: int) -> str: """Format timestamp as full datetime""" dt = datetime.fromtimestamp(timestamp) return dt.strftime('%B %d, %Y at %H:%M') @staticmethod def truncate(text: str, max_length: int) -> str: """Truncate text to max length""" if len(text) <= max_length: return text return text[:max_length].strip() + '...' @staticmethod def render_markdown(text: str) -> str: """Basic markdown rendering""" if not text: return '' # Basic markdown conversions html = text html = html.replace('&', '&').replace('<', '<').replace('>', '>') html = html.replace('\n\n', '

') html = html.replace('\n', '
') # Bold and italic import re html = re.sub(r'\*\*(.*?)\*\*', r'\1', html) html = re.sub(r'\*(.*?)\*', r'\1', html) # Images (must be processed before links since they use similar syntax) html = re.sub(r'!\[(.*?)\]\((.*?)\)', r'\1', html) # Links html = re.sub(r'\[(.*?)\]\((.*?)\)', r'\1', html) return f'

{html}

' @staticmethod def escape_html(text: str) -> str: """Escape HTML entities""" return (text .replace('&', '&') .replace('<', '<') .replace('>', '>') .replace('"', '"') .replace("'", '''))