From b6f96085a132044330dc48d19312b00298553982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 26 Feb 2026 14:13:04 +0100 Subject: [PATCH] commit --- .dockerignore | 9 ++ .env.example | 6 + .gitignore | 8 ++ Dockerfile | 10 ++ app/__init__.py | 34 ++++++ app/api.py | 263 ++++++++++++++++++++++++++++++++++++++++++++ app/config.py | 13 +++ app/static | 1 + app/templates | 1 + docker-compose.yml | 10 ++ entrypoint.sh | 2 + requirements.txt | 7 ++ static/css/app.css | 48 ++++++++ static/js/app.js | 226 +++++++++++++++++++++++++++++++++++++ templates/main.html | 92 ++++++++++++++++ 15 files changed, 730 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app/__init__.py create mode 100644 app/api.py create mode 100644 app/config.py create mode 120000 app/static create mode 120000 app/templates create mode 100644 docker-compose.yml create mode 100644 entrypoint.sh create mode 100644 requirements.txt create mode 100644 static/css/app.css create mode 100644 static/js/app.js create mode 100644 templates/main.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c047f80 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +__pycache__/ +venv/ +env/ +.venv/ +__pycache__/ +*.py[cod] +*$py.class +.env +tmp/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8836fb9 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +SECRET_KEY=secretKey +MAX_FILE_SIZE=16 +STATIC_CACHE_MAX_AGE=600 +APP_TITLE=AIO HTML Generator +APP_FOOTER=© 2026 Mateusz Gruszczyński +UPLOAD_FOLDER=/tmp/uploads diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68e6d12 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +venv/ +env/ +.venv/ +__pycache__/ +*.py[cod] +*$py.class +.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bf0eddf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.14-alpine +RUN apk add --no-cache gcc musl-dev linux-headers +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +RUN mkdir -p /tmp/uploads && chmod 777 /tmp/uploads +EXPOSE 5000 +USER 1000 +ENTRYPOINT ["./entrypoint.sh"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..7c49f93 --- /dev/null +++ b/app/__init__.py @@ -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 diff --git a/app/api.py b/app/api.py new file mode 100644 index 0000000..5308776 --- /dev/null +++ b/app/api.py @@ -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') + }) \ No newline at end of file diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..d4250fe --- /dev/null +++ b/app/config.py @@ -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') diff --git a/app/static b/app/static new file mode 120000 index 0000000..4dab164 --- /dev/null +++ b/app/static @@ -0,0 +1 @@ +../static \ No newline at end of file diff --git a/app/templates b/app/templates new file mode 120000 index 0000000..564a409 --- /dev/null +++ b/app/templates @@ -0,0 +1 @@ +../templates \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a1d91a2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +services: + app: + build: . + ports: + - "4578:5000" + volumes: + - ./uploads:/tmp/uploads + env_file: + - .env + restart: unless-stopped diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..3f2721f --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,2 @@ +#!/bin/sh +exec gunicorn -w 4 -b 0.0.0.0:5000 'app:create_app()' diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9485846 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +flask==3.0.3 +beautifulsoup4==4.12.3 +lxml +python-dotenv==1.0.1 +werkzeug==3.0.4 +gunicorn +cssutils diff --git a/static/css/app.css b/static/css/app.css new file mode 100644 index 0000000..664c771 --- /dev/null +++ b/static/css/app.css @@ -0,0 +1,48 @@ +.mode-btn { + aspect-ratio: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 1.5rem; + transition: all 0.2s; +} + +.mode-btn:hover, .mode-btn.active { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0,0,0,0.15); +} + +.mode-btn.active { + background-color: #0d6efd; + color: white; + border-color: #0d6efd; +} + +.drop-zone { + cursor: pointer; + transition: all 0.2s; + min-height: 280px; +} + +.drop-zone:hover { + border-color: #0d6efd; + box-shadow: 0 0 0 0.25rem rgba(13,110,253,0.1); +} + +.drop-zone.dragover { + border-color: #0d6efd !important; + background-color: rgba(13,110,253,0.05); +} + +.result-card { + border-radius: 0.75rem; + box-shadow: 0 4px 20px rgba(0,0,0,0.1); +} + +.preview-iframe { + width: 100%; + height: 300px; + border-radius: 0.5rem; + border: 1px solid #dee2e6; +} diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..d03cdc8 --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,226 @@ +document.addEventListener("DOMContentLoaded", () => { + const dropZone = document.getElementById("drop-zone"); + const fileInput = document.getElementById("file-input"); + const modeBtns = document.querySelectorAll(".mode-btn"); + const progress = document.getElementById("progress"); + const progressBar = progress.querySelector(".progress-bar"); + const progressText = document.getElementById("progress-text"); + const result = document.getElementById("result"); + + let currentMode = "basic"; + + modeBtns.forEach((btn) => { + btn.addEventListener("click", () => { + modeBtns.forEach((b) => b.classList.remove("active")); + btn.classList.add("active"); + currentMode = btn.dataset.mode || "basic"; + }); + }); + + ["dragenter", "dragover"].forEach((e) => + dropZone.addEventListener(e, (ev) => { + ev.preventDefault(); + dropZone.classList.add("dragover"); + }) + ); + + ["dragleave", "drop"].forEach((e) => + dropZone.addEventListener(e, (ev) => { + ev.preventDefault(); + dropZone.classList.remove("dragover"); + }) + ); + + dropZone.addEventListener("drop", (e) => { + e.preventDefault(); + fileInput.files = e.dataTransfer.files; + processFiles(); + }); + + fileInput.addEventListener("change", processFiles); + + dropZone.querySelector("label")?.addEventListener("click", (e) => { + e.preventDefault(); + fileInput.click(); + }); + + function processFiles() { + const files = fileInput.files; + if (!files || !files.length) return; + + const endpoint = + currentMode === "advanced" ? "/api/upload-advanced" : "/api/upload"; + + const formData = new FormData(); + Array.from(files).forEach((file) => formData.append("files", file)); + + showProgress(currentMode.toUpperCase(), files.length); + + fetch(endpoint, { method: "POST", body: formData }) + .then((r) => r.json()) + .then((data) => { + hideProgress(); + if (data && data.success) showResult(data); + else showError(data?.error || "Unknown error"); + }) + .catch((e) => { + hideProgress(); + showError(e?.message || "Request failed"); + }); + } + + function showProgress(mode, count) { + progress.classList.remove("d-none"); + progressText.classList.remove("d-none"); + progressText.textContent = `${mode} - ${count} files`; + progressBar.style.width = "45%"; + } + + function hideProgress() { + progress.classList.add("d-none"); + progressText.classList.add("d-none"); + } + + async function showResult(data) { + const sizeKB = ((data.size || 0) / 1024).toFixed(1); + + const rawHtml = + typeof data.aio_html === "string" ? data.aio_html : String(data.aio_html ?? ""); + + const formattedHtml = await formatHtml(rawHtml); + + result.innerHTML = ` +
+
+ +
+
${escapeHtml(data.message ?? "Done")}
+
+ ${escapeHtml(data.original ?? "")}${sizeKB}KB +
+
+
+ + + Save + +
+
+
+ +
+
+
+
+
Preview
+
+ +
+
+ +
+
+
+
Code
+ +
+
+${escapeHtml(formattedHtml)}
+
+
+
+ +
+ +
+ `; + + result.classList.remove("d-none"); + + const frame = result.querySelector("#previewFrame"); + if (frame) frame.srcdoc = rawHtml; + + if (window.Prism) window.Prism.highlightAllUnder(result); + + result.querySelectorAll(".btn-copy-sm, .btn-copy-all").forEach((btn) => { + const originalHtml = btn.innerHTML; + btn.addEventListener("click", () => { + navigator.clipboard + .writeText(rawHtml) + .then(() => { + btn.innerHTML = 'Copied'; + setTimeout(() => (btn.innerHTML = originalHtml), 1200); + }) + .catch(() => fallbackCopy(rawHtml, btn, originalHtml)); + }); + }); + + result.querySelector("#btn-new")?.addEventListener("click", () => { + location.reload(); + }); + + result.scrollIntoView({ behavior: "smooth" }); + } + + function fallbackCopy(text, btn, originalHtml) { + try { + const ta = document.createElement("textarea"); + ta.value = text; + document.body.appendChild(ta); + ta.select(); + document.execCommand("copy"); + document.body.removeChild(ta); + btn.innerHTML = 'Copied'; + setTimeout(() => (btn.innerHTML = originalHtml), 1200); + } catch { + showError("Clipboard blocked by browser"); + } + } + + function showError(msg) { + result.innerHTML = ` +
+ ${escapeHtml(msg)} +
+
+ +
+ `; + result.classList.remove("d-none"); + result.querySelector("#btn-try")?.addEventListener("click", () => location.reload()); + } +}); + +async function formatHtml(html) { + html = typeof html === "string" ? html : String(html ?? ""); + try { + if (window.prettier && window.prettierPlugins?.html) { + return await window.prettier.format(html, { + parser: "html", + plugins: [window.prettierPlugins.html], + printWidth: 100, + tabWidth: 2, + useTabs: false, + }); + } + } catch (e) { + console.error(e); + } + return html; +} + +function escapeHtml(text) { + text = typeof text === "string" ? text : String(text ?? ""); + const map = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; + return text.replace(/[&<>"']/g, (m) => map[m]); +} \ No newline at end of file diff --git a/templates/main.html b/templates/main.html new file mode 100644 index 0000000..217825e --- /dev/null +++ b/templates/main.html @@ -0,0 +1,92 @@ + + + + + + AIO Generator + + + + + + + + + + +
+
+
+ + +
+

AIO Generator

+

One HTML file from many

+
+ + +
+ + + +
+ + +
+ +

Drop files here

+

or click to browse

+ + +
+ + +
+
+
+
+ Processing... +
+ + +
+ +
+
+
+ + + + + + + + + \ No newline at end of file