From 0346cc0a008e13e7b163094b74c10b9a100f8288 Mon Sep 17 00:00:00 2001 From: Mike Gotfryd Date: Fri, 18 Apr 2025 22:36:01 -0500 Subject: [PATCH 1/2] additional compression --- action.yml | 42 ++++++++--- src/main.py | 204 ++++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 191 insertions(+), 55 deletions(-) diff --git a/action.yml b/action.yml index c3e39d0..19bbd62 100644 --- a/action.yml +++ b/action.yml @@ -1,10 +1,13 @@ name: 'DeploySlim' -description: 'Compress web assets (HTML, CSS, JS, etc.) with Brotli & Gzip in GitHub Actions.' +description: 'Optimize and compress web assets (HTML, CSS, JS, images) with Brotli, Gzip, minification, and image optimization for blazing-fast static websites.' author: 'CornerstoneCode' +branding: + icon: 'package' # Choose an icon from https://feathericons.com/ + color: 'blue' # Choose a color (e.g., blue, green, purple) inputs: directory: - description: 'The directory containing the web assets to compress.' + description: 'The directory containing the web assets to process.' required: false default: '.' algorithms: @@ -19,26 +22,47 @@ inputs: description: 'Gzip compression level (0-9). Default: 9' required: false default: '9' + minify: + description: 'Enable minification of HTML, CSS, and JS files. Default: true' + required: false + default: 'true' + optimize-images: + description: 'Enable image optimization for PNG, JPG, and WebP files. Default: true' + required: false + default: 'true' runs: using: 'composite' steps: - - name: Set up Python 3.x - uses: actions/setup-python@v3 + - name: Set up Python 3.12 + uses: actions/setup-python@v5 with: - python-version: '3.x' + python-version: '3.12' + + - name: Cache Python dependencies + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/action.yml') }} + restore-keys: | + ${{ runner.os }}-pip- - name: Install Python dependencies run: | + set -e python -m pip install --upgrade pip - pip install brotli + pip install brotli minify-html csscompressor jsmin Pillow shell: bash - - name: Run compression script - run: python main.py + - name: Run optimization and compression script + run: | + set -e + python main.py shell: bash env: INPUT_DIRECTORY: ${{ inputs.directory }} INPUT_ALGORITHMS: ${{ inputs.algorithms }} INPUT_BROTLI_LEVEL: ${{ inputs.brotli-level }} - INPUT_GZIP_LEVEL: ${{ inputs.gzip-level }} \ No newline at end of file + INPUT_GZIP_LEVEL: ${{ inputs.gzip-level }} + INPUT_MINIFY: ${{ inputs.minify }} + INPUT_OPTIMIZE_IMAGES: ${{ inputs.optimize-images }} \ No newline at end of file diff --git a/src/main.py b/src/main.py index cd0b4ea..3ab5826 100644 --- a/src/main.py +++ b/src/main.py @@ -2,58 +2,170 @@ import gzip import brotli import mimetypes +import multiprocessing +from concurrent.futures import ProcessPoolExecutor +from pathlib import Path +import logging +import minify_html +from csscompressor import compress as css_compress +from jsmin import jsmin +from PIL import Image -def compress_file(filepath, algorithms=['br', 'gz'], brotli_level=6, gzip_level=6): - """Compresses a file using specified algorithms and prints file size changes.""" +# Configure logging +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +COMPRESSIBLE_TYPES = { + 'text/html', 'text/css', 'text/plain', 'text/xml', + 'application/javascript', 'application/json', + 'application/xml', 'image/svg+xml', 'application/wasm', + 'font/woff', 'font/woff2', 'application/manifest+json' +} +COMPRESSIBLE_EXTENSIONS = { + '.html', '.css', '.js', '.json', '.xml', '.svg', '.wasm', + '.txt', '.woff', '.woff2', '.webmanifest', '.png', '.jpg', '.jpeg', '.webp' +} + +def is_compressible_file(filepath: str) -> bool: + path = Path(filepath) + if not path.is_file() or path.stat().st_size == 0: + return False + if path.suffix.lower() in COMPRESSIBLE_EXTENSIONS: + return True content_type, _ = mimetypes.guess_type(filepath) - if content_type and content_type.startswith(('text/', 'application/javascript', 'application/json', 'application/xml', 'image/svg+xml', 'application/wasm')): + return content_type in COMPRESSIBLE_TYPES + +def minify_file(filepath: str) -> None: + ext = Path(filepath).suffix.lower() + try: + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + if ext == '.html': + minified = minify_html.minify( + content, + minify_js=True, + minify_css=True, + remove_bangs=True, + remove_comments=True, + keep_closing_tags=True + ) + elif ext == '.css': + minified = css_compress(content) + elif ext == '.js': + minified = jsmin(content) + else: + return + with open(filepath, 'w', encoding='utf-8') as f: + f.write(minified) + logger.info(f"Minified {filepath}") + except Exception as e: + logger.error(f"Minification failed for {filepath}: {e}") + +def optimize_image(filepath: str) -> None: + ext = Path(filepath).suffix.lower() + try: + img = Image.open(filepath) + if ext in ('.png', '.jpg', '.jpeg'): + img.save(filepath, optimize=True, quality=85) + elif ext == '.webp': + img.save(filepath, 'WEBP', quality=80) + logger.info(f"Optimized image {filepath}") + except Exception as e: + logger.error(f"Image optimization failed for {filepath}: {e}") + +def compress_file(filepath: str, algorithms: list, brotli_level: int, gzip_level: int, do_minify: bool, do_optimize_images: bool) -> dict: + if not is_compressible_file(filepath): + return {'original': 0, 'br': 0, 'gz': 0} + + if do_minify and Path(filepath).suffix.lower() in ('.html', '.css', '.js'): + minify_file(filepath) + if do_optimize_images and Path(filepath).suffix.lower() in ('.png', '.jpg', '.jpeg', '.webp'): + optimize_image(filepath) + + original_size = os.path.getsize(filepath) + results = {'original': original_size, 'br': 0, 'gz': 0} + + if 'br' in algorithms: + br_path = f"{filepath}.br" try: - original_size = os.path.getsize(filepath) with open(filepath, 'rb') as f_in: content = f_in.read() - if 'br' in algorithms: - compressed_filepath_br = filepath + '.br' - try: - compressed_content_br = brotli.compress(content, quality=brotli_level) - with open(compressed_filepath_br, 'wb') as f_out: - f_out.write(compressed_content_br) - compressed_size_br = os.path.getsize(compressed_filepath_br) - print(f"Compressed '{filepath}' ({original_size} bytes) to '{compressed_filepath_br}' ({compressed_size_br} bytes) using Brotli.") - except Exception as e: - print(f"Error compressing '{filepath}' with Brotli: {e}") - if 'gz' in algorithms: - compressed_filepath_gz = filepath + '.gz' - try: - with gzip.open(compressed_filepath_gz, 'wb', compresslevel=gzip_level) as f_out: - f_out.write(content) - compressed_size_gz = os.path.getsize(compressed_filepath_gz) - print(f"Compressed '{filepath}' ({original_size} bytes) to ({compressed_size_gz} bytes) using Gzip.") - except Exception as e: - print(f"Error compressing '{filepath}' with Gzip: {e}") - except FileNotFoundError: - print(f"Error: File '{filepath}' not found.") + with open(br_path, 'wb') as f_out: + f_out.write(brotli.compress(content, quality=brotli_level)) + results['br'] = os.path.getsize(br_path) + logger.info(f"Compressed {filepath} to {br_path} ({results['br']} bytes)") + except Exception as e: + logger.error(f"Brotli compression failed for {filepath}: {e}") + + if 'gz' in algorithms: + gz_path = f"{filepath}.gz" + try: + with gzip.open(gz_path, 'wb', compresslevel=gzip_level) as f_out: + with open(filepath, 'rb') as f_in: + f_out.write(f_in.read()) + results['gz'] = os.path.getsize(gz_path) + logger.info(f"Compressed {filepath} to {gz_path} ({results['gz']} bytes)") except Exception as e: - print(f"An error occurred while processing '{filepath}': {e}") + logger.error(f"Gzip compression failed for {filepath}: {e}") + + return results + +def process_file(filepath: str, algorithms: list, brotli_level: int, gzip_level: int, minify: bool, optimize_images: bool) -> dict: + """Wrapper function to call compress_file, picklable for ProcessPoolExecutor.""" + return compress_file(filepath, algorithms, brotli_level, gzip_level, minify, optimize_images) + +def process_directory(directory: str, algorithms: list, brotli_level: int, gzip_level: int, minify: bool, optimize_images: bool) -> None: + files = [ + os.path.join(root, filename) + for root, _, files in os.walk(directory) + for filename in files + if is_compressible_file(os.path.join(root, filename)) + ] + + if not files: + logger.info("No files to process.") + return -def process_directory(directory, algorithms, brotli_level, gzip_level): - """Processes all files in a directory.""" - for root, _, files in os.walk(directory): - for filename in files: - filepath = os.path.join(root, filename) - compress_file(filepath, algorithms, brotli_level, gzip_level) + total_original = 0 + total_compressed_br = 0 + total_compressed_gz = 0 + + with ProcessPoolExecutor(max_workers=multiprocessing.cpu_count()) as executor: + results = list(executor.map( + process_file, + files, + [algorithms] * len(files), + [brotli_level] * len(files), + [gzip_level] * len(files), + [minify] * len(files), + [optimize_images] * len(files) + )) + + for result in results: + total_original += result['original'] + total_compressed_br += result['br'] + total_compressed_gz += result['gz'] + + logger.info( + f"Summary:\n" + f"- Files processed: {len(files)}\n" + f"- Original size: {total_original} bytes\n" + f"- Brotli size: {total_compressed_br} bytes\n" + f"- Gzip size: {total_compressed_gz} bytes" + ) if __name__ == "__main__": - target_directory = os.environ.get("INPUT_DIRECTORY") - algorithms_str = os.environ.get("INPUT_ALGORITHMS", "br,gz") - brotli_level = int(os.environ.get("INPUT_BROTLI_LEVEL", "6")) - gzip_level = int(os.environ.get("INPUT_GZIP_LEVEL", "6")) - - if not target_directory: - print("Error: 'directory' input is required.") - elif not os.path.isdir(target_directory): - print(f"Error: Directory '{target_directory}' not found.") - else: - algorithms_list = [alg.strip() for alg in algorithms_str.split(',')] - print(f"Processing directory: {target_directory}") - process_directory(target_directory, algorithms_list, brotli_level, gzip_level) - print("Compression complete.") \ No newline at end of file + directory = os.environ.get("INPUT_DIRECTORY", ".") + algorithms = os.environ.get("INPUT_ALGORITHMS", "br,gz").split(',') + brotli_level = int(os.environ.get("INPUT_BROTLI_LEVEL", "11")) + gzip_level = int(os.environ.get("INPUT_GZIP_LEVEL", "9")) + minify = os.environ.get("INPUT_MINIFY", "true").lower() == "true" + optimize_images = os.environ.get("INPUT_OPTIMIZE_IMAGES", "true").lower() == "true" + + if not os.path.isdir(directory): + logger.error(f"Invalid directory: {directory}") + exit(1) + + logger.info(f"Processing directory: {directory}") + process_directory(directory, algorithms, brotli_level, gzip_level, minify, optimize_images) + logger.info("Processing complete.") \ No newline at end of file From 560d6d9f57d9fa5a5abeaad6b4bca08493e144a6 Mon Sep 17 00:00:00 2001 From: Mike Gotfryd Date: Fri, 18 Apr 2025 22:50:24 -0500 Subject: [PATCH 2/2] fix pip import --- action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yml b/action.yml index 19bbd62..e7ef2e0 100644 --- a/action.yml +++ b/action.yml @@ -51,7 +51,7 @@ runs: run: | set -e python -m pip install --upgrade pip - pip install brotli minify-html csscompressor jsmin Pillow + pip install brotli minify_html csscompressor jsmin Pillow shell: bash - name: Run optimization and compression script