- Flask-based web application with PostgreSQL - User authentication and session management - Content moderation and filtering - Docker deployment with docker-compose - Admin interface for content management 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
516 lines
18 KiB
Python
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('&', '&').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("'", '''))
|