BalanceBoard - Clean release
- Docker deployment ready
- Content aggregation and filtering
- User authentication
- Polling service for updates
🤖 Generated with Claude Code
This commit is contained in:
515
html_generation_lib.py
Normal file
515
html_generation_lib.py
Normal file
@@ -0,0 +1,515 @@
|
||||
"""
|
||||
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('&', '&').replace('<', '<').replace('>', '>')
|
||||
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('&', '&')
|
||||
.replace('<', '<')
|
||||
.replace('>', '>')
|
||||
.replace('"', '"')
|
||||
.replace("'", '''))
|
||||
Reference in New Issue
Block a user