Files
balanceboard/html_generation_lib.py
chelsea cb894b2159 BalanceBoard - Clean release
- Docker deployment ready
- Content aggregation and filtering
- User authentication
- Polling service for updates

🤖 Generated with Claude Code
2025-10-11 21:24:21 +00:00

516 lines
18 KiB
Python

"""
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> tag if present
if '<template' in template_string:
import re
match = re.search(r'<template[^>]*>(.*?)</template>', 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"<!-- Template error: {e} -->"
@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"<!-- No template found for {template_name} -->"
# 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' <link rel="stylesheet" href="{adjusted_path}">\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' <script src="{adjusted_path}"></script>\n'
# Create full page
page_html = f'''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{post.get('title', 'Post')} - BalanceBoard</title>
{css_links}
</head>
<body>
<!-- BalanceBoard Navigation -->
<nav class="balanceboard-nav">
<div class="nav-container">
<a href="/index.html" class="nav-brand">
<img src="../../logo.png" alt="BalanceBoard Logo" class="nav-logo">
<div>
<div class="nav-brand-text">
<span class="brand-balance">balance</span><span class="brand-board">Board</span>
</div>
<div class="nav-subtitle">Filtered Content Feed</div>
</div>
</a>
</div>
</nav>
<div class="app-layout">
<!-- Sidebar -->
<aside class="sidebar">
<!-- User Card -->
<div class="sidebar-section user-card">
<div class="login-prompt">
<div class="user-avatar">?</div>
<p>Join BalanceBoard to customize your feed</p>
<a href="/login" class="btn-login">Log In</a>
<a href="/signup" class="btn-signup">Sign Up</a>
</div>
</div>
<!-- Navigation -->
<div class="sidebar-section">
<h3>Navigation</h3>
<ul class="nav-menu">
<li><a href="/index.html" class="nav-item">
<span class="nav-icon">🏠</span>
<span>Home</span>
</a></li>
<li><a href="#" class="nav-item">
<span class="nav-icon">🔥</span>
<span>Popular</span>
</a></li>
<li><a href="#" class="nav-item">
<span class="nav-icon">⭐</span>
<span>Saved</span>
</a></li>
<li><a href="#" class="nav-item">
<span class="nav-icon">📊</span>
<span>Analytics</span>
</a></li>
</ul>
</div>
<!-- Post Info -->
<div class="sidebar-section">
<h3>Post Info</h3>
<p style="font-size: 0.85rem; color: var(--text-secondary); line-height: 1.5; margin-bottom: 12px;">
<strong>Author:</strong> {post.get('author', 'Unknown')}<br>
<strong>Platform:</strong> {post.get('platform', 'Unknown').title()}<br>
<strong>Score:</strong> {post.get('score', 0)} points
</p>
</div>
<!-- About -->
<div class="sidebar-section">
<h3>About</h3>
<p style="font-size: 0.85rem; color: var(--text-secondary); line-height: 1.5;">
BalanceBoard filters and curates content from multiple platforms.
</p>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<div class="container">
{post_content}
</div>
</main>
</div>
{js_scripts}
</body>
</html>'''
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 '<!-- No comment template -->'
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 '<!-- No list template -->'
# 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' <link rel="stylesheet" href="{adjusted_path}">\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' <script src="{adjusted_path}"></script>\n'
page_html = f'''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BalanceBoard - Content Feed</title>
{css_links}
</head>
<body>
<!-- BalanceBoard Navigation -->
<nav class="balanceboard-nav">
<div class="nav-container">
<a href="index.html" class="nav-brand">
<img src="../../logo.png" alt="BalanceBoard Logo" class="nav-logo">
<div>
<div class="nav-brand-text">
<span class="brand-balance">balance</span><span class="brand-board">Board</span>
</div>
<div class="nav-subtitle">Filtered Content Feed</div>
</div>
</a>
</div>
</nav>
<div class="app-layout">
<!-- Sidebar -->
<aside class="sidebar">
<!-- User Card -->
<div class="sidebar-section user-card">
<div class="login-prompt">
<div class="user-avatar">?</div>
<p>Join BalanceBoard to customize your feed</p>
<a href="/login" class="btn-login">Log In</a>
<a href="/signup" class="btn-signup">Sign Up</a>
</div>
</div>
<!-- Navigation -->
<div class="sidebar-section">
<h3>Navigation</h3>
<ul class="nav-menu">
<li><a href="index.html" class="nav-item active">
<span class="nav-icon">🏠</span>
<span>Home</span>
</a></li>
<li><a href="#" class="nav-item">
<span class="nav-icon">🔥</span>
<span>Popular</span>
</a></li>
<li><a href="#" class="nav-item">
<span class="nav-icon">⭐</span>
<span>Saved</span>
</a></li>
<li><a href="#" class="nav-item">
<span class="nav-icon">📊</span>
<span>Analytics</span>
</a></li>
</ul>
</div>
<!-- Filters -->
<div class="sidebar-section">
<h3>Filter by Platform</h3>
<div class="filter-tags">
<a href="#" class="filter-tag active">All</a>
<a href="#" class="filter-tag">Reddit</a>
<a href="#" class="filter-tag">HackerNews</a>
<a href="#" class="filter-tag">Lobsters</a>
</div>
</div>
<!-- About -->
<div class="sidebar-section">
<h3>About</h3>
<p style="font-size: 0.85rem; color: var(--text-secondary); line-height: 1.5;">
BalanceBoard filters and curates content from multiple platforms to help you stay informed.
</p>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<div class="container">
<header>
<h1>{filterset_name.replace('_', ' ').title() if filterset_name else 'All Posts'}</h1>
<p class="post-count">{len(posts)} posts</p>
</header>
<div id="posts-container">
{''.join(post_items)}
</div>
</div>
</main>
</div>
</body>
</html>'''
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('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
html = html.replace('\n\n', '</p><p>')
html = html.replace('\n', '<br>')
# Bold and italic
import re
html = re.sub(r'\*\*(.*?)\*\*', r'<strong>\1</strong>', html)
html = re.sub(r'\*(.*?)\*', r'<em>\1</em>', html)
# Images (must be processed before links since they use similar syntax)
html = re.sub(r'!\[(.*?)\]\((.*?)\)', r'<img src="\2" alt="\1" style="max-width: 100%; height: auto; display: block; margin: 0.5em 0;" />', html)
# Links
html = re.sub(r'\[(.*?)\]\((.*?)\)', r'<a href="\2" target="_blank">\1</a>', html)
return f'<p>{html}</p>'
@staticmethod
def escape_html(text: str) -> str:
"""Escape HTML entities"""
return (text
.replace('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;')
.replace('"', '&quot;')
.replace("'", '&#x27;'))