This commit is contained in:
Mateusz Gruszczyński
2026-02-26 14:13:04 +01:00
commit b6f96085a1
15 changed files with 730 additions and 0 deletions

34
app/__init__.py Normal file
View File

@@ -0,0 +1,34 @@
from flask import Flask, request, render_template
from .config import Config
from .api import api_bp
def create_app():
app = Flask(__name__)
app.config.from_object(Config)
app.register_blueprint(api_bp, url_prefix='/api')
@app.route('/favicon.ico')
def favicon():
return '', 204
@app.after_request
def add_cache_headers(response):
if request.path.startswith('/static/'):
max_age = app.config['STATIC_CACHE_MAX_AGE']
response.headers['Cache-Control'] = f'public, max-age={max_age}'
return response
@app.route('/')
def index():
template_vars = {
'APP_TITLE': app.config['APP_TITLE'],
'APP_FOOTER': app.config['APP_FOOTER']
}
return render_template('main.html', config=template_vars)
@app.route('/api/health')
def health():
return {'status': 'OK'}
return app

263
app/api.py Normal file
View File

@@ -0,0 +1,263 @@
from flask import Blueprint, request, jsonify, current_app
from werkzeug.utils import secure_filename
import os
import mimetypes
import base64
from bs4 import BeautifulSoup
import re
import cssutils
api_bp = Blueprint('api', __name__, url_prefix='/api')
ALLOWED_EXTENSIONS = {
'html', 'htm', 'css', 'js', 'png', 'jpg', 'jpeg', 'gif',
'svg', 'ico', 'woff', 'woff2', 'ttf', 'eot', 'json', 'map'
}
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def guess_mimetype(filepath):
mime_type, _ = mimetypes.guess_type(filepath)
return mime_type or 'application/octet-stream'
def build_file_index(uploaded_paths):
"""
Index files by basename so resources referenced as /img.png, img.png, assets/img.png
still resolve even if uploads were flattened.
"""
idx = {}
for p in uploaded_paths:
base = os.path.basename(p)
if base not in idx:
idx[base] = p
return idx
def resolve_uploaded_path(base_path, resource_path, file_index):
"""
Try:
1) base_path + resource_path (as-is, normalized)
2) base_path + lstrip('/') (for /img.png)
3) basename lookup in file_index (fallback)
"""
if not resource_path:
return None
clean = resource_path.split('#', 1)[0].split('?', 1)[0]
p1 = os.path.normpath(os.path.join(base_path, clean))
if os.path.exists(p1):
return p1
p2 = os.path.normpath(os.path.join(base_path, clean.lstrip('/')))
if os.path.exists(p2):
return p2
base = os.path.basename(clean)
if base in file_index and os.path.exists(file_index[base]):
return file_index[base]
return None
def embed_resource(base_path, resource_path, file_index):
"""Convert resource to base64 data URI."""
if not resource_path:
return resource_path
if resource_path.startswith(('http://', 'https://', 'data:', '//')):
return resource_path
full_path = resolve_uploaded_path(base_path, resource_path, file_index)
if not full_path:
current_app.logger.warning(f"File not found for embed: {resource_path}")
return resource_path
try:
with open(full_path, 'rb') as f:
data = f.read()
mime = guess_mimetype(full_path)
b64 = base64.b64encode(data).decode('utf-8')
return f"data:{mime};base64,{b64}"
except Exception as e:
current_app.logger.error(f"Failed to embed {full_path}: {e}")
return resource_path
def embed_css_resources(base_path, css_content, file_index):
"""Embed CSS url() resources."""
def repl(match):
url = match.group(1).strip("'\"")
data_uri = embed_resource(base_path, url, file_index)
return f'url({data_uri})'
return re.sub(r'url\s*\(\s*["\']?([^"\')]+)["\']?\s*\)', repl, css_content)
def resolve_css_imports(css_file_path, file_index):
"""Recursively resolve @import statements in CSS."""
cssutils.log.setLevel(30)
visited = set()
def parse_css_recursively(css_path):
if css_path in visited:
return ''
visited.add(css_path)
base_path = os.path.dirname(os.path.abspath(css_path))
try:
sheet = cssutils.parseFile(css_path)
css_text = sheet.cssText.decode('utf-8')
for rule in sheet.cssRules.rulesOfType(cssutils.css.CSSImportRule):
import_path = rule.href
if not import_path or import_path.startswith(('http', 'data:')):
continue
import_full = resolve_uploaded_path(base_path, import_path, file_index)
if import_full and os.path.exists(import_full):
imported_css = parse_css_recursively(import_full)
css_text = (
imported_css
+ f'\n/* ===== IMPORTED: {os.path.basename(import_full)} ===== */\n'
+ css_text
)
return css_text
except Exception as e:
current_app.logger.warning(f"CSS parse error {css_path}: {e}")
try:
with open(css_path, 'r', encoding='utf-8', errors='ignore') as f:
return f.read()
except:
return f"/* ERROR: Could not read {os.path.basename(css_path)} */"
return parse_css_recursively(css_file_path)
def make_aio_html(input_file, file_index):
"""Generate basic AIO HTML with embedded resources."""
base_path = os.path.dirname(os.path.abspath(input_file))
with open(input_file, 'r', encoding='utf-8', errors='ignore') as f:
soup = BeautifulSoup(f.read(), 'html.parser')
for script in soup.find_all('script', src=True):
script['src'] = embed_resource(base_path, script['src'], file_index)
for elem in soup.find_all(attrs={'src': True}):
elem['src'] = embed_resource(base_path, elem['src'], file_index)
for style in soup.find_all('style'):
if style.string:
style.string = embed_css_resources(base_path, style.string, file_index)
return str(soup)
def make_aio_html_advanced(input_file, file_index):
"""Advanced AIO: resolves @import CSS chains + embeds everything."""
base_path = os.path.dirname(os.path.abspath(input_file))
with open(input_file, 'r', encoding='utf-8', errors='ignore') as f:
soup = BeautifulSoup(f.read(), 'html.parser')
css_files = []
for link in soup.find_all('link', rel='stylesheet'):
href = link.get('href')
if href and not href.startswith(('http', 'data:')):
full_css = resolve_uploaded_path(base_path, href, file_index)
if full_css and os.path.exists(full_css):
css_files.append(full_css)
else:
current_app.logger.warning(f"CSS file not found: {href}")
all_css_content = ''
for css_path in css_files:
resolved = resolve_css_imports(css_path, file_index)
resolved = embed_css_resources(os.path.dirname(os.path.abspath(css_path)), resolved, file_index)
all_css_content += f'\n/* ===== {os.path.basename(css_path)} ===== */\n{resolved}\n'
if all_css_content.strip():
style_tag = soup.new_tag('style')
style_tag.string = all_css_content
if soup.head:
soup.head.insert(0, style_tag)
else:
soup.insert(0, style_tag)
for link in soup.find_all('link', rel='stylesheet'):
href = link.get('href')
if href and not href.startswith(('http', 'data:')):
link.decompose()
for script in soup.find_all('script', src=True):
script['src'] = embed_resource(base_path, script['src'], file_index)
for elem in soup.find_all(attrs={'src': True}):
elem['src'] = embed_resource(base_path, elem['src'], file_index)
for style in soup.find_all('style'):
if style.string:
style.string = embed_css_resources(base_path, style.string, file_index)
return str(soup)
@api_bp.route('/upload', methods=['POST'])
def upload_files():
return _upload_and_generate(make_aio_html)
@api_bp.route('/upload-advanced', methods=['POST'])
def upload_advanced():
return _upload_and_generate(make_aio_html_advanced)
def _upload_and_generate(generator_func):
if 'files' not in request.files:
return jsonify({'error': 'No files provided'}), 400
files = request.files.getlist('files')
uploaded_paths = []
upload_folder = current_app.config.get('UPLOAD_FOLDER', '/tmp/uploads')
os.makedirs(upload_folder, exist_ok=True)
for file in files:
if not file or file.filename == '':
continue
if not allowed_file(file.filename):
continue
filename = secure_filename(os.path.basename(file.filename))
filepath = os.path.join(upload_folder, filename)
file.save(filepath)
uploaded_paths.append(filepath)
if not uploaded_paths:
return jsonify({'error': 'No valid files uploaded'}), 400
html_path = next((p for p in uploaded_paths if p.lower().endswith(('.html', '.htm'))), None)
if not html_path:
return jsonify({'error': 'No HTML file found. Please upload an HTML file.'}), 400
file_index = build_file_index(uploaded_paths)
try:
aio_content = generator_func(html_path, file_index)
return jsonify({
'success': True,
'message': 'AIO HTML generated successfully',
'aio_html': aio_content,
'size': len(aio_content),
'original': os.path.basename(html_path),
'features': 'CSS @import resolved, all assets embedded'
if 'advanced' in generator_func.__name__
else 'Basic embedding'
})
except Exception as e:
current_app.logger.error(f"AIO generation failed: {e}")
return jsonify({'error': f'Generation failed: {str(e)}'}), 500
@api_bp.route('/health', methods=['GET'])
def health_check():
return jsonify({
'status': 'OK',
'version': '2.0.1',
'features': ['Basic AIO', 'Advanced @import CSS', 'Asset embedding (basename fallback)'],
'upload_folder': current_app.config.get('UPLOAD_FOLDER')
})

13
app/config.py Normal file
View File

@@ -0,0 +1,13 @@
import os
from dotenv import load_dotenv
from flask import g
load_dotenv()
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-key-aio-generator-change-me'
MAX_CONTENT_LENGTH = int(os.environ.get('MAX_FILE_SIZE', '32')) * 1024 * 1024
STATIC_CACHE_MAX_AGE = int(os.environ.get('STATIC_CACHE_MAX_AGE', '31536000'))
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', '/tmp/uploads')
APP_TITLE = os.environ.get('APP_TITLE', 'AIO HTML Generator')
APP_FOOTER = os.environ.get('APP_FOOTER', '© 2026 - All-In-One HTML Generator')

1
app/static Symbolic link
View File

@@ -0,0 +1 @@
../static

1
app/templates Symbolic link
View File

@@ -0,0 +1 @@
../templates