diff --git a/.changeset/fix-gzip-stream-and-action-build.md b/.changeset/fix-gzip-stream-and-action-build.md new file mode 100644 index 000000000..5c612d70a --- /dev/null +++ b/.changeset/fix-gzip-stream-and-action-build.md @@ -0,0 +1,8 @@ +--- +"@node-minify/utils": patch +"@node-minify/action": patch +--- + +fix: improve gzip size stream handling in utils +fix: ensure action build fails if type definitions copy fails +docs: add documentation for action inputs and java-version migration diff --git a/.changeset/fluffy-eagles-sing.md b/.changeset/fluffy-eagles-sing.md new file mode 100644 index 000000000..d8d64a984 --- /dev/null +++ b/.changeset/fluffy-eagles-sing.md @@ -0,0 +1,8 @@ +--- +"@node-minify/utils": minor +"@node-minify/benchmark": minor +"@node-minify/docs": patch +--- + +feat: add `getFilesizeGzippedRaw` utility and update benchmark defaults +feat(action): launch `@node-minify/action` GitHub Action diff --git a/.github/actions/node-minify/README.md b/.github/actions/node-minify/README.md index 0310dc840..0e91ffde8 100644 --- a/.github/actions/node-minify/README.md +++ b/.github/actions/node-minify/README.md @@ -1,41 +1,94 @@ -# node-minify GitHub Action +# node-minify GitHub Action (DEPRECATED) -Minify JavaScript, CSS, and HTML files in your CI/CD pipeline with detailed reporting. +> **This action is deprecated.** Please use the new bundled action instead: +> +> ```yaml +> - uses: srod/node-minify@v1 +> ``` -## Usage +The new action includes: +- Bundled dependencies (faster startup) +- PR comment reporting +- File annotations +- Benchmark comparison +- Threshold enforcement +- More compressor options -### Basic Example +## Migration + +Replace: ```yaml -- name: Minify JavaScript - uses: srod/node-minify/.github/actions/node-minify@main +- uses: srod/node-minify/.github/actions/node-minify@main + with: + input: "src/app.js" + output: "dist/app.min.js" + compressor: "terser" +``` + +With: + +```yaml +- uses: srod/node-minify@v1 with: input: "src/app.js" output: "dist/app.min.js" compressor: "terser" ``` -### With All Options +### Removed Inputs + +The following inputs are **not supported** in the new action and must be removed from your workflow YAML: + +| Removed Input | Migration Guide | +|---------------|-----------------| +| `include-gzip` | Gzip sizes are now always included in the output. No action needed. | +| `java-version` | Use `actions/setup-java@v4` before running the action (see example below). | + +#### Java Compressors Migration + +If you use `gcc` or `yui` compressors that require Java: + +**Before (deprecated):** +```yaml +- uses: srod/node-minify/.github/actions/node-minify@main + with: + compressor: gcc + java-version: "17" +``` + +**After:** +```yaml +- uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + +- uses: srod/node-minify@v1 + with: + compressor: gcc +``` + +See [packages/action/README.md](../../../packages/action/README.md) for full documentation. + +--- + +## Legacy Documentation + +The following documentation is for the deprecated composite action. + +### Basic Example ```yaml -- name: Minify with full options - id: minify +- name: Minify JavaScript uses: srod/node-minify/.github/actions/node-minify@main with: input: "src/app.js" output: "dist/app.min.js" - compressor: "esbuild" - type: "js" - options: '{"minify": true}' - report-summary: "true" - include-gzip: "true" - -- name: Show results - run: | - echo "Reduction: ${{ steps.minify.outputs.reduction-percent }}%" + compressor: "terser" ``` -## Inputs +### Inputs | Input | Description | Required | Default | |-------|-------------|----------|---------| @@ -48,30 +101,7 @@ Minify JavaScript, CSS, and HTML files in your CI/CD pipeline with detailed repo | `include-gzip` | Include gzip sizes | No | `true` | | `java-version` | Java version for gcc/yui | No | - | -### Available Compressors - -**JavaScript (no Java required):** -- `terser` (recommended) -- `esbuild` (fastest) -- `swc` -- `oxc` -- `uglify-js` - -**CSS (no Java required):** -- `lightningcss` (recommended) -- `clean-css` -- `cssnano` -- `csso` -- `esbuild` - -**HTML:** -- `html-minifier` - -**Requires Java:** -- `gcc` (Google Closure Compiler) -- `yui` (deprecated) - -## Outputs +### Outputs | Output | Description | |--------|-------------| @@ -84,49 +114,6 @@ Minify JavaScript, CSS, and HTML files in your CI/CD pipeline with detailed repo | `gzip-size-formatted` | Gzipped size formatted | | `time-ms` | Compression time in milliseconds | -## Examples - -### CSS Minification - -```yaml -- name: Minify CSS - uses: srod/node-minify/.github/actions/node-minify@main - with: - input: "src/styles.css" - output: "dist/styles.min.css" - compressor: "lightningcss" - type: "css" -``` - -### Using Google Closure Compiler - -```yaml -- name: Setup Java - uses: actions/setup-java@v4 - with: - distribution: "temurin" - java-version: "17" - -- name: Minify with GCC - uses: srod/node-minify/.github/actions/node-minify@main - with: - input: "src/app.js" - output: "dist/app.min.js" - compressor: "gcc" - options: '{"compilation_level": "ADVANCED_OPTIMIZATIONS"}' -``` - -### HTML Minification - -```yaml -- name: Minify HTML - uses: srod/node-minify/.github/actions/node-minify@main - with: - input: "src/index.html" - output: "dist/index.html" - compressor: "html-minifier" -``` - ## License MIT diff --git a/.github/actions/node-minify/action.yml b/.github/actions/node-minify/action.yml index d52cb84b7..210d0f40a 100644 --- a/.github/actions/node-minify/action.yml +++ b/.github/actions/node-minify/action.yml @@ -1,9 +1,9 @@ -name: "node-minify" -description: "Minify JavaScript, CSS, and HTML files with detailed reporting" +name: "node-minify (deprecated)" +description: "DEPRECATED: Use srod/node-minify@v1 instead. This composite action will be removed in a future release." author: "srod" branding: icon: "minimize-2" - color: "green" + color: "gray" inputs: input: @@ -20,7 +20,7 @@ inputs: description: "Output file path" required: true type: - description: "File type: js or css (required for esbuild, lightningcss, yui)" + description: "File type: js or css (required for esbuild, yui)" required: false options: description: "Compressor-specific options (JSON string)" @@ -67,7 +67,11 @@ outputs: runs: using: "composite" steps: - # Setup Java for gcc/yui compressors (auto-setup with default if java-version not specified) + - name: Deprecation warning + shell: bash + run: | + echo "::warning::This action (.github/actions/node-minify) is DEPRECATED. Please migrate to 'uses: srod/node-minify@v1' for the new bundled action with more features (PR comments, annotations, benchmarking)." + - name: Setup Java (for gcc/yui) if: contains(fromJSON('["gcc", "google-closure-compiler", "yui"]'), inputs.compressor) uses: actions/setup-java@v4 @@ -75,7 +79,6 @@ runs: distribution: "temurin" java-version: ${{ inputs.java-version || '17' }} - # Warn about deprecated yui compressor - name: Deprecation warning (yui) if: inputs.compressor == 'yui' shell: bash @@ -121,7 +124,7 @@ runs: shell: bash run: | cat >> $GITHUB_STEP_SUMMARY << EOF - ## 📦 node-minify Results + ## node-minify Results | Metric | Value | |--------|-------| @@ -134,4 +137,6 @@ runs: | **Gzip Size** | ${{ steps.minify.outputs.gzip-size-formatted }} | | **Time** | ${{ steps.minify.outputs.time-ms }}ms | + > **Note:** This action is deprecated. Please migrate to \`uses: srod/node-minify@v1\` for enhanced features. + EOF diff --git a/.github/actions/node-minify/minify.ts b/.github/actions/node-minify/minify.ts index d8f579fe5..2bec5b456 100644 --- a/.github/actions/node-minify/minify.ts +++ b/.github/actions/node-minify/minify.ts @@ -2,65 +2,10 @@ import { appendFileSync, existsSync } from "node:fs"; import { stat } from "node:fs/promises"; import { resolve } from "node:path"; import { minify } from "@node-minify/core"; -import { getFilesizeGzippedInBytes } from "@node-minify/utils"; - -const KNOWN_COMPRESSOR_EXPORTS: Record = { - esbuild: "esbuild", - "google-closure-compiler": "gcc", - gcc: "gcc", - oxc: "oxc", - swc: "swc", - terser: "terser", - "uglify-js": "uglifyJs", - "babel-minify": "babelMinify", - "uglify-es": "uglifyEs", - yui: "yui", - "clean-css": "cleanCss", - cssnano: "cssnano", - csso: "csso", - lightningcss: "lightningCss", - crass: "crass", - sqwish: "sqwish", - "html-minifier": "htmlMinifier", - jsonminify: "jsonMinify", - imagemin: "imagemin", - sharp: "sharp", - svgo: "svgo", - "no-compress": "noCompress", -}; - -/** - * Resolves a compressor function from the @node-minify/{name} package and returns it with a label. - * - * @param name - Compressor package identifier (e.g., "terser", "esbuild"); used to import @node-minify/{name} - * @returns An object containing `compressor` (the resolved compressor function) and `label` (the provided name) - * @throws Error if the package does not export a usable compressor function - */ -async function resolveCompressor( - name: string -): Promise<{ compressor: unknown; label: string }> { - const packageName = `@node-minify/${name}`; - const mod = (await import(packageName)) as Record; - - const knownExport = KNOWN_COMPRESSOR_EXPORTS[name]; - if (knownExport && typeof mod[knownExport] === "function") { - return { compressor: mod[knownExport], label: name }; - } - - if (typeof mod.default === "function") { - return { compressor: mod.default, label: name }; - } - - for (const value of Object.values(mod)) { - if (typeof value === "function") { - return { compressor: value, label: name }; - } - } - - throw new Error( - `Package '${packageName}' doesn't export a valid compressor function.` - ); -} +import { + getFilesizeGzippedInBytes, + resolveCompressor, +} from "@node-minify/utils"; interface ActionResult { originalSize: number; @@ -164,9 +109,7 @@ async function run(): Promise { console.log(`Minifying ${inputFile} with ${label}...`); - const requiresType = ["esbuild", "lightningcss", "yui"].includes( - compressorName - ); + const requiresType = ["esbuild", "yui"].includes(compressorName); if (requiresType && !fileType) { console.error( `::error::Compressor '${compressorName}' requires the 'type' input (js or css)` diff --git a/.github/workflows/release-action.yml b/.github/workflows/release-action.yml new file mode 100644 index 000000000..6e9f393e0 --- /dev/null +++ b/.github/workflows/release-action.yml @@ -0,0 +1,131 @@ +name: Release GitHub Action + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Tag to release (e.g., v1.0.0)" + required: true + type: string + +concurrency: release-${{ github.event.release.tag_name || github.event.inputs.tag || github.ref_name }} + +permissions: + contents: write + +jobs: + release-action: + name: Build and Release Action + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.tag || (github.event.release.tag_name || github.ref) }} + fetch-depth: 0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.5" + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build dependencies + run: | + bun run build:deps + bun run --filter '@node-minify/core' build + + - name: Build action + run: bun run build + working-directory: packages/action + + - name: Get version + id: version + env: + INPUT_TAG: ${{ github.event.inputs.tag }} + REF_NAME: ${{ github.ref_name }} + run: | + if [ -n "$INPUT_TAG" ]; then + VERSION="$INPUT_TAG" + else + VERSION="$REF_NAME" + fi + + # Validate that VERSION looks like a tag (starts with v followed by numbers) + if ! [[ "$VERSION" =~ ^v[0-9]+(\.[0-9]+)*$ ]]; then + echo "Error: Version '$VERSION' is not a valid release tag (must start with 'v' e.g. v1.0.0)" + exit 1 + fi + + echo "version=$VERSION" >> $GITHUB_OUTPUT + + # Extract major version using regex + if [[ "$VERSION" =~ ^v([0-9]+) ]]; then + MAJOR="v${BASH_REMATCH[1]}" + echo "major=$MAJOR" >> $GITHUB_OUTPUT + else + echo "Error: Could not extract major version from '$VERSION'" + exit 1 + fi + + - name: Ensure we're on a branch + run: | + # If in detached HEAD, create a temporary branch + if ! git symbolic-ref -q HEAD; then + git checkout -b temp-release-branch + fi + + - name: Commit built action dist + id: commit + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Force add dist (ignored by .gitignore) + git add -f packages/action/dist/ + + # Only commit if there are staged changes + if git diff --cached --quiet; then + echo "No changes to commit (dist files unchanged)" + echo "skip_tag_update=true" >> $GITHUB_OUTPUT + else + git commit -m "chore: build action dist for ${{ steps.version.outputs.version }}" + echo "skip_tag_update=false" >> $GITHUB_OUTPUT + fi + + - name: Update release tag + if: steps.commit.outputs.skip_tag_update != 'true' + run: | + # Update the release tag to point at HEAD (with dist files) and push + VERSION="${{ steps.version.outputs.version }}" + git tag -fa "$VERSION" -m "Release $VERSION with built dist" + git push origin "refs/tags/$VERSION" --force + + - name: Update major version tag + run: | + # Force update the major version tag (e.g., v1) + MAJOR="${{ steps.version.outputs.major }}" + git tag -fa "$MAJOR" -m "Update $MAJOR tag to ${{ steps.version.outputs.version }}" + if ! git push origin "refs/tags/$MAJOR" --force; then + echo "Error: Failed to push major version tag $MAJOR" + exit 1 + fi + + - name: Create action release artifact + run: | + mkdir -p release-action/packages/action + cp action.yml release-action/ + cp -r packages/action/dist release-action/packages/action/ + cp packages/action/README.md release-action/ + cd release-action + zip -r ../node-minify-action-${{ steps.version.outputs.version }}.zip . + + - name: Upload release artifact + if: github.event_name == 'release' + uses: softprops/action-gh-release@v2 + with: + files: node-minify-action-${{ steps.version.outputs.version }}.zip diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml index 5959f6d31..ba13c8d18 100644 --- a/.github/workflows/test-action.yml +++ b/.github/workflows/test-action.yml @@ -2,23 +2,81 @@ name: Test node-minify Action on: push: - branches: [feature/github-action] + branches: [main, develop, feature/github-action-package] paths: - - ".github/actions/node-minify/**" + - "packages/action/**" + - "action.yml" - ".github/workflows/test-action.yml" pull_request: paths: - - ".github/actions/node-minify/**" + - "packages/action/**" + - "action.yml" - ".github/workflows/test-action.yml" workflow_dispatch: jobs: + build-action: + name: Build Action + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.5" + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build dependencies + run: | + bun run build:deps + bun run --filter '@node-minify/core' build + bun run --filter '@node-minify/benchmark' build + + - name: Build action + run: bun run build + working-directory: packages/action + + - name: Upload built action + uses: actions/upload-artifact@v4 + with: + name: action-dist + path: packages/action/dist/ + retention-days: 1 + test-js-minification: name: Test JS Minification runs-on: ubuntu-latest + needs: build-action steps: - uses: actions/checkout@v4 + - name: Download built action + uses: actions/download-artifact@v4 + with: + name: action-dist + path: packages/action/dist/ + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install compressor packages + # Compressors contain native dependencies (esbuild binaries, swc binaries, etc.) + # that cannot be bundled. Users must install the compressor package they need. + # See packages/action/README.md for documentation. + run: | + mkdir -p /tmp/compressors && cd /tmp/compressors + npm init -y + npm install @node-minify/terser @node-minify/esbuild + + # Create node_modules and copy packages + mkdir -p $GITHUB_WORKSPACE/node_modules + cp -r /tmp/compressors/node_modules/* $GITHUB_WORKSPACE/node_modules/ + - name: Create test file run: | mkdir -p test-action @@ -38,7 +96,7 @@ jobs: - name: Minify with terser id: minify-terser - uses: ./.github/actions/node-minify + uses: ./ with: input: "test-action/input.js" output: "test-action/output.min.js" @@ -46,16 +104,16 @@ jobs: - name: Verify terser outputs env: - ORIGINAL_SIZE: ${{ steps.minify-terser.outputs.original-size-formatted }} - MINIFIED_SIZE: ${{ steps.minify-terser.outputs.minified-size-formatted }} + ORIGINAL_SIZE: ${{ steps.minify-terser.outputs.original-size }} + MINIFIED_SIZE: ${{ steps.minify-terser.outputs.minified-size }} REDUCTION_PERCENT: ${{ steps.minify-terser.outputs.reduction-percent }} - GZIP_SIZE: ${{ steps.minify-terser.outputs.gzip-size-formatted }} + GZIP_SIZE: ${{ steps.minify-terser.outputs.gzip-size }} TIME_MS: ${{ steps.minify-terser.outputs.time-ms }} run: | - echo "Original size: $ORIGINAL_SIZE" - echo "Minified size: $MINIFIED_SIZE" + echo "Original size: $ORIGINAL_SIZE bytes" + echo "Minified size: $MINIFIED_SIZE bytes" echo "Reduction: $REDUCTION_PERCENT%" - echo "Gzip size: $GZIP_SIZE" + echo "Gzip size: $GZIP_SIZE bytes" echo "Time: ${TIME_MS}ms" if [ ! -f "test-action/output.min.js" ]; then @@ -73,7 +131,7 @@ jobs: - name: Minify with esbuild id: minify-esbuild - uses: ./.github/actions/node-minify + uses: ./ with: input: "test-action/input.js" output: "test-action/output.esbuild.js" @@ -82,24 +140,48 @@ jobs: - name: Compare results env: - TERSER_SIZE: ${{ steps.minify-terser.outputs.minified-size-formatted }} + TERSER_SIZE: ${{ steps.minify-terser.outputs.minified-size }} TERSER_REDUCTION: ${{ steps.minify-terser.outputs.reduction-percent }} - ESBUILD_SIZE: ${{ steps.minify-esbuild.outputs.minified-size-formatted }} + ESBUILD_SIZE: ${{ steps.minify-esbuild.outputs.minified-size }} ESBUILD_REDUCTION: ${{ steps.minify-esbuild.outputs.reduction-percent }} run: | echo "## Comparison" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "| Compressor | Minified Size | Reduction |" >> $GITHUB_STEP_SUMMARY echo "|------------|---------------|-----------|" >> $GITHUB_STEP_SUMMARY - echo "| terser | $TERSER_SIZE | $TERSER_REDUCTION% |" >> $GITHUB_STEP_SUMMARY - echo "| esbuild | $ESBUILD_SIZE | $ESBUILD_REDUCTION% |" >> $GITHUB_STEP_SUMMARY + echo "| terser | ${TERSER_SIZE} bytes | ${TERSER_REDUCTION}% |" >> $GITHUB_STEP_SUMMARY + echo "| esbuild | ${ESBUILD_SIZE} bytes | ${ESBUILD_REDUCTION}% |" >> $GITHUB_STEP_SUMMARY test-css-minification: name: Test CSS Minification runs-on: ubuntu-latest + needs: build-action steps: - uses: actions/checkout@v4 + - name: Download built action + uses: actions/download-artifact@v4 + with: + name: action-dist + path: packages/action/dist/ + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install compressor packages + # Compressors contain native dependencies that cannot be bundled. + # See packages/action/README.md for documentation. + run: | + mkdir -p /tmp/compressors && cd /tmp/compressors + npm init -y + npm install @node-minify/lightningcss + + # Create node_modules and copy packages + mkdir -p $GITHUB_WORKSPACE/node_modules + cp -r /tmp/compressors/node_modules/* $GITHUB_WORKSPACE/node_modules/ + - name: Create test CSS file run: | mkdir -p test-action @@ -129,7 +211,7 @@ jobs: - name: Minify CSS with lightningcss id: minify-css - uses: ./.github/actions/node-minify + uses: ./ with: input: "test-action/input.css" output: "test-action/output.min.css" @@ -138,8 +220,8 @@ jobs: - name: Verify CSS output run: | - echo "Original: ${{ steps.minify-css.outputs.original-size-formatted }}" - echo "Minified: ${{ steps.minify-css.outputs.minified-size-formatted }}" + echo "Original: ${{ steps.minify-css.outputs.original-size }} bytes" + echo "Minified: ${{ steps.minify-css.outputs.minified-size }} bytes" echo "Reduction: ${{ steps.minify-css.outputs.reduction-percent }}%" if [ "$(echo '${{ steps.minify-css.outputs.reduction-percent }} > 0' | bc -l)" -ne 1 ]; then @@ -155,4 +237,4 @@ jobs: needs: [test-js-minification, test-css-minification] steps: - name: All tests passed - run: echo "✅ All node-minify action tests passed!" + run: echo "All node-minify action tests passed!" diff --git a/AGENTS.md b/AGENTS.md index 2466890bd..25df81965 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -273,7 +273,7 @@ node-minify --compressor terser --input src/app.js --output dist/app.min.js node-minify -c esbuild -i "src/**/*.js" -o dist/bundle.js -t js -O '{"minify":true}' # Benchmark -node-minify benchmark src/app.js --compressors terser,esbuild,swc --format json +node-minify benchmark src/app.js --compressors terser,esbuild,swc,oxc --format json ``` **Benchmark formats**: `console` (default, colored tables), `json`, `markdown` @@ -299,12 +299,12 @@ The repository includes a reusable GitHub Action at `.github/actions/node-minify ### Usage ```yaml -- uses: srod/node-minify/.github/actions/node-minify@main +- uses: srod/node-minify@v1 with: input: "src/app.js" output: "dist/app.min.js" compressor: "terser" # or esbuild, swc, lightningcss, etc. - type: "js" # required for esbuild, lightningcss, yui + type: "js" # required for esbuild, yui ``` ### Key Behaviors @@ -315,8 +315,8 @@ The repository includes a reusable GitHub Action at `.github/actions/node-minify ### Files | File | Purpose | |------|---------| -| `action.yml` | Action definition, inputs/outputs, composite steps | -| `minify.ts` | Bun script that runs minification, writes GitHub outputs | +| `action.yml` | Action definition, inputs/outputs | +| `packages/action/` | Source code for the action (built to `packages/action/dist/index.js`) | ## CI/CD Workflows diff --git a/action.yml b/action.yml new file mode 100644 index 000000000..f451a9f04 --- /dev/null +++ b/action.yml @@ -0,0 +1,93 @@ +name: "node-minify" +description: "Minify JavaScript, CSS, and HTML files with detailed reporting and PR comments" +author: "srod" +branding: + icon: "minimize-2" + color: "green" + +inputs: + input: + description: "Files to minify (glob pattern or path)" + required: true + output: + description: "Output file path" + required: true + compressor: + description: | + Compressor to use. + Recommended: terser, esbuild, swc, oxc (fast, no Java). + Note: 'gcc' and 'yui' require Java (pre-installed on GitHub runners). + required: false + default: "terser" + type: + description: "File type: js or css (required for esbuild, yui)" + required: false + options: + description: "Compressor-specific options (JSON string)" + required: false + default: "{}" + report-summary: + description: "Add results to job summary" + required: false + default: "true" + report-pr-comment: + description: "Post results as PR comment" + required: false + default: "false" + report-annotations: + description: "Add file annotations for warnings" + required: false + default: "false" + benchmark: + description: "Run benchmark comparison across compressors" + required: false + default: "false" + benchmark-compressors: + description: "Compressors to compare (comma-separated)" + required: false + default: "terser,esbuild,swc,oxc" + fail-on-increase: + description: "Fail if minified size is larger than original" + required: false + default: "false" + min-reduction: + description: "Minimum reduction % required (0-100)" + required: false + default: "0" + include-gzip: + description: "Include gzip sizes in report" + required: false + default: "true" + working-directory: + description: "Working directory for file operations" + required: false + default: "." + github-token: + description: "GitHub token for PR comments" + required: false + +outputs: + original-size: + description: "Original file size in bytes" + minified-size: + description: "Minified file size in bytes" + reduction-percent: + description: "Size reduction percentage" + gzip-size: + description: "Gzipped size in bytes" + time-ms: + description: "Compression time in milliseconds" + report-json: + description: "Full report as JSON string" + benchmark-winner: + description: "Best compressor from benchmark (if run)" + best-compression: + description: "Compressor with best compression ratio (if benchmark run)" + best-speed: + description: "Fastest compressor (if benchmark run)" + benchmark-json: + description: "Full benchmark results as JSON string" + +runs: + using: "node20" + main: "packages/action/dist/index.js" diff --git a/bun.lock b/bun.lock index 5cabb7356..4f3ffd48f 100644 --- a/bun.lock +++ b/bun.lock @@ -68,6 +68,25 @@ "@node-minify/yui": "workspace:*", }, }, + "packages/action": { + "name": "@node-minify/action", + "version": "10.3.0", + "dependencies": { + "@actions/core": "^1.11.1", + "@actions/github": "^6.0.0", + "@node-minify/benchmark": "workspace:*", + "@node-minify/core": "workspace:*", + "@node-minify/esbuild": "workspace:*", + "@node-minify/oxc": "workspace:*", + "@node-minify/swc": "workspace:*", + "@node-minify/terser": "workspace:*", + "@node-minify/utils": "workspace:*", + }, + "devDependencies": { + "@node-minify/types": "workspace:*", + "@vercel/ncc": "^0.38.3", + }, + }, "packages/babel-minify": { "name": "@node-minify/babel-minify", "version": "10.3.0", @@ -413,6 +432,16 @@ }, }, "packages": { + "@actions/core": ["@actions/core@1.11.1", "", { "dependencies": { "@actions/exec": "^1.1.1", "@actions/http-client": "^2.0.1" } }, "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A=="], + + "@actions/exec": ["@actions/exec@1.1.1", "", { "dependencies": { "@actions/io": "^1.0.1" } }, "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w=="], + + "@actions/github": ["@actions/github@6.0.1", "", { "dependencies": { "@actions/http-client": "^2.2.0", "@octokit/core": "^5.0.1", "@octokit/plugin-paginate-rest": "^9.2.2", "@octokit/plugin-rest-endpoint-methods": "^10.4.0", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "undici": "^5.28.5" } }, "sha512-xbZVcaqD4XnQAe35qSQqskb3SqIAfRyLBrHMd/8TuL7hJSz2QtbDwnNM8zWx4zO5l2fnGtseNE3MbEvD7BxVMw=="], + + "@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], + + "@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="], + "@andrewbranch/untar.js": ["@andrewbranch/untar.js@1.0.3", "", {}, "sha512-Jh15/qVmrLGhkKJBdXlK1+9tY4lZruYjsgkDFj08ZmDiWVBLJcqkok7Z0/R0In+i1rScBpJlSvrTS2Lm41Pbnw=="], "@arethetypeswrong/cli": ["@arethetypeswrong/cli@0.18.2", "", { "dependencies": { "@arethetypeswrong/core": "0.18.2", "chalk": "^4.1.2", "cli-table3": "^0.6.3", "commander": "^10.0.1", "marked": "^9.1.2", "marked-terminal": "^7.1.0", "semver": "^7.5.4" }, "bin": { "attw": "dist/index.js" } }, "sha512-PcFM20JNlevEDKBg4Re29Rtv2xvjvQZzg7ENnrWFSS0PHgdP2njibVFw+dRUhNkPgNfac9iUqO0ohAXqQL4hbw=="], @@ -625,6 +654,8 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], + "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], + "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], @@ -713,6 +744,8 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + "@node-minify/action": ["@node-minify/action@workspace:packages/action"], + "@node-minify/babel-minify": ["@node-minify/babel-minify@workspace:packages/babel-minify"], "@node-minify/benchmark": ["@node-minify/benchmark@workspace:packages/benchmark"], @@ -779,6 +812,26 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@octokit/auth-token": ["@octokit/auth-token@4.0.0", "", {}, "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA=="], + + "@octokit/core": ["@octokit/core@5.2.2", "", { "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@octokit/types": "^13.0.0", "before-after-hook": "^2.2.0", "universal-user-agent": "^6.0.0" } }, "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg=="], + + "@octokit/endpoint": ["@octokit/endpoint@9.0.6", "", { "dependencies": { "@octokit/types": "^13.1.0", "universal-user-agent": "^6.0.0" } }, "sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw=="], + + "@octokit/graphql": ["@octokit/graphql@7.1.1", "", { "dependencies": { "@octokit/request": "^8.4.1", "@octokit/types": "^13.0.0", "universal-user-agent": "^6.0.0" } }, "sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g=="], + + "@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], + + "@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@9.2.2", "", { "dependencies": { "@octokit/types": "^12.6.0" }, "peerDependencies": { "@octokit/core": "5" } }, "sha512-u3KYkGF7GcZnSD/3UP0S7K5XUFT2FkOQdcfXZGZQPGv3lm4F2Xbf71lvjldr8c1H3nNbF+33cLEkWYbokGWqiQ=="], + + "@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@10.4.1", "", { "dependencies": { "@octokit/types": "^12.6.0" }, "peerDependencies": { "@octokit/core": "5" } }, "sha512-xV1b+ceKV9KytQe3zCVqjg+8GTGfDYwaT1ATU5isiUyVtlVAO3HNdzpS4sr4GBx4hxQ46s7ITtZrAsxG22+rVg=="], + + "@octokit/request": ["@octokit/request@8.4.1", "", { "dependencies": { "@octokit/endpoint": "^9.0.6", "@octokit/request-error": "^5.1.1", "@octokit/types": "^13.1.0", "universal-user-agent": "^6.0.0" } }, "sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw=="], + + "@octokit/request-error": ["@octokit/request-error@5.1.1", "", { "dependencies": { "@octokit/types": "^13.1.0", "deprecation": "^2.0.0", "once": "^1.4.0" } }, "sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g=="], + + "@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="], + "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], "@oxc-minify/binding-android-arm-eabi": ["@oxc-minify/binding-android-arm-eabi@0.108.0", "", { "os": "android", "cpu": "arm" }, "sha512-obfkLrlAv40lAE6C9eYameBKLpTJ/ToynpBbTwb+wSVg+HXYzLoFYy1M5V9/otjCnxxVpPdnHsOqw8aGCRT0WA=="], @@ -1045,6 +1098,8 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@vercel/ncc": ["@vercel/ncc@0.38.4", "", { "bin": { "ncc": "dist/ncc/cli.js" } }, "sha512-8LwjnlP39s08C08J5NstzriPvW1SP8Zfpp1BvC2sI35kPeZnHfxVkCwu4/+Wodgnd60UtT1n8K8zw+Mp7J9JmQ=="], + "@vitest/coverage-v8": ["@vitest/coverage-v8@4.0.16", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.0.16", "ast-v8-to-istanbul": "^0.3.8", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.2.0", "magicast": "^0.5.1", "obug": "^2.1.1", "std-env": "^3.10.0", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "@vitest/browser": "4.0.16", "vitest": "4.0.16" }, "optionalPeers": ["@vitest/browser"] }, "sha512-2rNdjEIsPRzsdu6/9Eq0AYAzYdpP6Bx9cje9tL3FE5XzXRQF1fNU9pe/1yE8fCrS0HD+fBtt6gLPh6LI57tX7A=="], "@vitest/expect": ["@vitest/expect@4.0.16", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.16", "@vitest/utils": "4.0.16", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA=="], @@ -1293,6 +1348,8 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.9.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ=="], + "before-after-hook": ["before-after-hook@2.2.3", "", {}, "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ=="], + "better-path-resolve": ["better-path-resolve@1.0.0", "", { "dependencies": { "is-windows": "^1.0.0" } }, "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g=="], "bin-build": ["bin-build@3.0.0", "", { "dependencies": { "decompress": "^4.0.0", "download": "^6.2.2", "execa": "^0.7.0", "p-map-series": "^1.0.0", "tempfile": "^2.0.0" } }, "sha512-jcUOof71/TNAI2uM5uoUaDq2ePcVBQ3R/qhxAz1rX7UfvduAL/RXD3jXzvn8cVcDJdGVkiR1shal3OH0ImpuhA=="], @@ -1499,6 +1556,8 @@ "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], + "deprecation": ["deprecation@2.3.1", "", {}, "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], @@ -2583,6 +2642,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], @@ -2607,7 +2668,7 @@ "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], - "undici": ["undici@7.14.0", "", {}, "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ=="], + "undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], @@ -2643,6 +2704,8 @@ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + "universal-user-agent": ["universal-user-agent@6.0.1", "", {}, "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ=="], + "universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], "unrun": ["unrun@0.2.21", "", { "dependencies": { "rolldown": "1.0.0-beta.57" }, "peerDependencies": { "synckit": "^0.11.11" }, "optionalPeers": ["synckit"], "bin": { "unrun": "dist/cli.mjs" } }, "sha512-VuwI4YKtwBpDvM7hCEop2Im/ezS82dliqJpkh9pvS6ve8HcUsBDvESHxMmUfImXR03GkmfdDynyrh/pUJnlguw=="], @@ -2795,6 +2858,10 @@ "@manypkg/get-packages/globby": ["globby@11.1.0", "", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="], + "@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="], + + "@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="], + "@pnpm/network.ca-file/graceful-fs": ["graceful-fs@4.2.10", "", {}, "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="], "@poppinss/colors/kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], @@ -2987,6 +3054,8 @@ "miniflare/sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="], + "miniflare/undici": ["undici@7.14.0", "", {}, "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ=="], + "miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="], "node-emoji/@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], @@ -3099,6 +3168,10 @@ "@manypkg/get-packages/globby/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@20.0.0", "", {}, "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA=="], + + "@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@20.0.0", "", {}, "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA=="], + "@preact/preset-vite/@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@preact/preset-vite/@rollup/pluginutils/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -3511,6 +3584,8 @@ "@astrojs/cloudflare/wrangler/miniflare/sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="], + "@astrojs/cloudflare/wrangler/miniflare/undici": ["undici@7.14.0", "", {}, "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ=="], + "@astrojs/cloudflare/wrangler/miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="], "@astrojs/cloudflare/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20251118.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-UmWmYEYS/LkK/4HFKN6xf3Hk8cw70PviR+ftr3hUvs9HYZS92IseZEp16pkL6ZBETrPRpZC7OrzoYF7ky6kHsg=="], diff --git a/docs/src/consts.ts b/docs/src/consts.ts index 5e525fdca..c4f7fbab7 100644 --- a/docs/src/consts.ts +++ b/docs/src/consts.ts @@ -12,6 +12,7 @@ export const SIDEBAR: Sidebar = { { text: "Custom Compressors", link: "custom-compressors" }, { text: "Options", link: "options" }, { text: "CLI", link: "cli" }, + { text: "GitHub Action", link: "github-action" }, { text: "Benchmark", link: "benchmark" }, ], Compressors: [ diff --git a/docs/src/content/docs/benchmark.md b/docs/src/content/docs/benchmark.md index dda1c8202..f67a796af 100644 --- a/docs/src/content/docs/benchmark.md +++ b/docs/src/content/docs/benchmark.md @@ -28,7 +28,7 @@ node-minify benchmark src/app.js ### Compare Specific Compressors ```bash -node-minify benchmark src/app.js --compressors terser,esbuild,swc +node-minify benchmark src/app.js --compressors terser,esbuild,swc,oxc ``` ### Custom Compressors @@ -51,7 +51,7 @@ node-minify benchmark src/app.js -c terser,esbuild -n 3 # Include gzip size in results node-minify benchmark src/app.js -c terser,esbuild --gzip -# Specify file type (required for esbuild, lightningcss) +# Specify file type (required for esbuild) node-minify benchmark src/app.js -c terser,esbuild -t js ``` @@ -84,7 +84,7 @@ import { benchmark } from '@node-minify/benchmark'; const results = await benchmark({ input: 'src/app.js', - compressors: ['terser', 'esbuild', 'swc'] + compressors: ['terser', 'esbuild', 'swc', 'oxc'] }); console.log(results.summary.recommended); @@ -124,7 +124,7 @@ for (const file of results.files) { | Option | Type | Description | Default | |--------|------|-------------|---------| | `input` | `string \| string[]` | File(s) or glob pattern to benchmark | Required | -| `compressors` | `string[]` | List of compressor names | `['terser', 'esbuild', 'swc']` | +| `compressors` | `string[]` | List of compressor names | `['terser', 'esbuild', 'swc', 'oxc']` | | `iterations` | `number` | Number of iterations per compressor | `1` | | `warmup` | `number` | Warmup runs before timing | `1` if iterations > 1 | | `includeGzip` | `boolean` | Include gzip size in results | `false` | diff --git a/docs/src/content/docs/cli.md b/docs/src/content/docs/cli.md index fe55624e4..34de0b892 100644 --- a/docs/src/content/docs/cli.md +++ b/docs/src/content/docs/cli.md @@ -78,7 +78,7 @@ node-minify benchmark src/app.js ### Compare Specific Compressors ```bash -node-minify benchmark src/app.js --compressors terser,esbuild,swc +node-minify benchmark src/app.js --compressors terser,esbuild,swc,oxc ``` ### With Options @@ -105,7 +105,7 @@ node-minify benchmark src/app.js -c terser,esbuild -f markdown | Option | Description | Default | |--------|-------------|---------| -| `-c, --compressors` | Comma-separated list of compressors | `terser,esbuild,swc` | +| `-c, --compressors` | Comma-separated list of compressors | `terser,esbuild,swc,oxc` | | `-n, --iterations` | Number of iterations | `1` | | `-f, --format` | Output format: `console`, `json`, `markdown` | `console` | | `-o, --output` | Output file path | stdout | diff --git a/docs/src/content/docs/github-action.md b/docs/src/content/docs/github-action.md new file mode 100644 index 000000000..32272195e --- /dev/null +++ b/docs/src/content/docs/github-action.md @@ -0,0 +1,267 @@ +--- +title: "GitHub Action" +description: "Use node-minify in your CI/CD pipelines with the official GitHub Action" +--- + +Minify JavaScript, CSS, and HTML files directly in your GitHub workflows with detailed reporting. + +## Features + +- Bundled dependencies (fast startup) +- PR comment reporting +- Job summary with compression stats +- File annotations for warnings +- Benchmark comparison across compressors +- Threshold enforcement (fail on size increase) +- Support for 22+ compressors + +## Quick Start + +```yaml +- name: Minify JavaScript + uses: srod/node-minify@v1 + with: + input: "src/app.js" + output: "dist/app.min.js" + compressor: "terser" +``` + +## Usage Examples + +### Basic Minification + +```yaml +name: Build +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Minify JS + uses: srod/node-minify@v1 + with: + input: "src/app.js" + output: "dist/app.min.js" + compressor: "terser" +``` + +### With PR Comments + +```yaml +- name: Minify and Report + uses: srod/node-minify@v1 + with: + input: "src/app.js" + output: "dist/app.min.js" + compressor: "esbuild" + type: "js" + report-summary: "true" + report-pr-comment: "true" + github-token: ${{ secrets.GITHUB_TOKEN }} +``` + +### CSS Minification + +```yaml +- name: Minify CSS + uses: srod/node-minify@v1 + with: + input: "src/styles.css" + output: "dist/styles.min.css" + compressor: "lightningcss" + type: "css" +``` + +### With Quality Gates + +```yaml +- name: Minify with Thresholds + uses: srod/node-minify@v1 + with: + input: "src/app.js" + output: "dist/app.min.js" + compressor: "terser" + fail-on-increase: "true" + min-reduction: "20" +``` + +### Benchmark Comparison + +```yaml +- name: Benchmark Compressors + uses: srod/node-minify@v1 + with: + input: "src/app.js" + output: "dist/app.min.js" + compressor: "terser" + benchmark: "true" + benchmark-compressors: "terser,esbuild,swc,oxc" +``` + +## Inputs + +| Input | Description | Required | Default | +|-------|-------------|----------|---------| +| `input` | Files to minify (glob pattern or path) | Yes | - | +| `output` | Output file path | Yes | - | +| `compressor` | Compressor to use | No | `terser` | +| `type` | File type: `js` or `css` | No | - | +| `options` | Compressor options (JSON) | No | `{}` | +| `report-summary` | Add results to job summary | No | `true` | +| `report-pr-comment` | Post results as PR comment | No | `false` | +| `report-annotations` | Add file annotations | No | `false` | +| `benchmark` | Run benchmark comparison | No | `false` | +| `benchmark-compressors` | Compressors to compare | No | `terser,esbuild,swc,oxc` | +| `fail-on-increase` | Fail if size increases | No | `false` | +| `min-reduction` | Minimum reduction % (0-100) | No | `0` | +| `include-gzip` | Include gzip sizes | No | `true` | +| `working-directory` | Working directory | No | `.` | +| `github-token` | Token for PR comments | No | - | + +### Type Parameter + +The `type` parameter is **required** for: +- `esbuild` (specify `js` or `css`) +- `yui` (specify `js` or `css`) + +### Available Compressors + +**JavaScript:** +- `terser` (recommended) +- `esbuild` (fastest, requires `type: js`) +- `swc` +- `oxc` +- `uglify-js` +- `google-closure-compiler` / `gcc` (requires Java) + +**CSS:** +- `lightningcss` (recommended, CSS-only) +- `clean-css` +- `cssnano` +- `csso` +- `esbuild` (requires `type: css`) + +**HTML:** +- `html-minifier` +- `minify-html` + +**JSON:** +- `jsonminify` + +**Image:** +- `sharp` (WebP/AVIF conversion) +- `svgo` (SVG optimization) +- `imagemin` (PNG/JPEG/GIF) + +**Other:** +- `no-compress` (passthrough) + +## Outputs + +| Output | Description | +|--------|-------------| +| `original-size` | Original size in bytes | +| `minified-size` | Minified size in bytes | +| `reduction-percent` | Size reduction percentage | +| `gzip-size` | Gzipped size in bytes | +| `time-ms` | Compression time in ms | +| `report-json` | Full report as JSON | +| `benchmark-winner` | Best compressor (if benchmark run) | + +### Using Outputs + +```yaml +- name: Minify + id: minify + uses: srod/node-minify@v1 + with: + input: "src/app.js" + output: "dist/app.min.js" + compressor: "terser" + +- name: Show Results + run: | + echo "Original: ${{ steps.minify.outputs.original-size }} bytes" + echo "Minified: ${{ steps.minify.outputs.minified-size }} bytes" + echo "Reduction: ${{ steps.minify.outputs.reduction-percent }}%" +``` + +## Advanced Examples + +### Google Closure Compiler + +```yaml +- name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + +- name: Minify with GCC + uses: srod/node-minify@v1 + with: + input: "src/app.js" + output: "dist/app.min.js" + compressor: "gcc" + options: '{"compilation_level": "ADVANCED_OPTIMIZATIONS"}' +``` + +### HTML Minification + +```yaml +- name: Minify HTML + uses: srod/node-minify@v1 + with: + input: "src/index.html" + output: "dist/index.html" + compressor: "html-minifier" + options: '{"collapseWhitespace": true, "removeComments": true}' +``` + +### Multiple Files + +```yaml +- name: Minify JS bundle + uses: srod/node-minify@v1 + with: + input: "src/**/*.js" + output: "dist/bundle.min.js" + compressor: "terser" + +- name: Minify CSS bundle + uses: srod/node-minify@v1 + with: + input: "src/**/*.css" + output: "dist/styles.min.css" + compressor: "lightningcss" + type: "css" +``` + +## Job Summary + +When `report-summary` is enabled (default), the action adds a detailed summary to your workflow run showing: + +- Input/output files +- Original and minified sizes +- Compression percentage +- Gzip size +- Processing time + +## PR Comments + +Enable `report-pr-comment` to automatically post compression results as a comment on pull requests. Requires `github-token` to be set. + +## Deprecation Notices + +The following compressors are deprecated and will emit warnings: + +| Deprecated | Use Instead | +|------------|-------------| +| `babel-minify` | `terser` | +| `uglify-es` | `terser` | +| `yui` | `terser` (JS) or `lightningcss` (CSS) | +| `crass` | `lightningcss` or `clean-css` | +| `sqwish` | `lightningcss` or `clean-css` | diff --git a/packages/action/README.md b/packages/action/README.md new file mode 100644 index 000000000..711c79eb4 --- /dev/null +++ b/packages/action/README.md @@ -0,0 +1,149 @@ +# @node-minify/action + +GitHub Action for minifying JavaScript, CSS, and HTML files with detailed reporting. + +## Features + +- 📦 **Minification** - Compress JS, CSS, HTML files using 15+ compressors +- 📊 **Job Summary** - Detailed compression statistics in workflow UI +- 💬 **PR Comments** - Automatic size reports on pull requests +- ⚠️ **Annotations** - File-level warnings for low compression +- 🎯 **Thresholds** - Fail builds on size regressions +- 🏁 **Benchmark** - Compare compressor performance + +## Usage + +### Prerequisites + +Compressor packages contain native dependencies that cannot be bundled into the action. You must install the compressor package you want to use before running the action: + +```yaml +- name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + +- name: Install compressor + run: npm install @node-minify/terser + +- name: Minify JavaScript + uses: srod/node-minify@v1 + with: + input: "src/app.js" + output: "dist/app.min.js" + compressor: "terser" +``` + +### Basic Minification + +```yaml +- name: Minify JavaScript + uses: srod/node-minify@v1 + with: + input: "src/app.js" + output: "dist/app.min.js" + compressor: "terser" +``` + +### With PR Comment + +```yaml +- name: Minify and Report + uses: srod/node-minify@v1 + with: + input: "src/app.js" + output: "dist/app.min.js" + compressor: "esbuild" + type: "js" + report-summary: true + report-pr-comment: true + github-token: ${{ secrets.GITHUB_TOKEN }} +``` + +### With Thresholds + +```yaml +- name: Minify with Quality Gates + uses: srod/node-minify@v1 + with: + input: "src/app.js" + output: "dist/app.min.js" + compressor: "terser" + fail-on-increase: true + min-reduction: 50 +``` + +## Inputs + +| Input | Description | Required | Default | +|-------|-------------|----------|---------| +| `input` | Files to minify (glob pattern or path) | Yes | - | +| `output` | Output file path | Yes | - | +| `compressor` | Compressor to use | No | `terser` | +| `type` | File type: js or css | No | - | +| `options` | Compressor options (JSON) | No | `{}` | +| `report-summary` | Add results to job summary | No | `true` | +| `report-pr-comment` | Post results as PR comment | No | `false` | +| `report-annotations` | Add file annotations | No | `false` | +| `fail-on-increase` | Fail if size increases | No | `false` | +| `min-reduction` | Minimum reduction % | No | `0` | +| `include-gzip` | Include gzip sizes | No | `true` | +| `github-token` | Token for PR comments | No | - | + +### Available Compressors + +**JavaScript:** +- `terser` (recommended) +- `esbuild` (fastest, requires `type: js`) +- `swc` +- `oxc` +- `uglify-js` +- `google-closure-compiler` / `gcc` (requires Java) + +**CSS:** +- `lightningcss` (recommended, CSS-only) +- `clean-css` +- `cssnano` +- `csso` +- `esbuild` (requires `type: css`) + +**HTML:** +- `html-minifier` +- `minify-html` + +**JSON:** +- `jsonminify` + +**Image:** +- `sharp` (WebP/AVIF conversion) +- `svgo` (SVG optimization) +- `imagemin` (PNG/JPEG/GIF) + +**Other:** +- `no-compress` (passthrough, concatenation only) + +**Deprecated (emit warnings):** +- `babel-minify` → use `terser` +- `uglify-es` → use `terser` +- `yui` → use `terser` (JS) or `lightningcss` (CSS) +- `crass` → use `lightningcss` or `clean-css` +- `sqwish` → use `lightningcss` or `clean-css` + +## Outputs + +| Output | Description | +|--------|-------------| +| `original-size` | Original size in bytes | +| `minified-size` | Minified size in bytes | +| `reduction-percent` | Size reduction percentage | +| `gzip-size` | Gzipped size in bytes | +| `time-ms` | Compression time in ms | +| `report-json` | Full report as JSON | +| `benchmark-winner` | Best compressor from benchmark (if run) | +| `best-compression` | Compressor with best compression ratio (if benchmark run) | +| `best-speed` | Fastest compressor (if benchmark run) | +| `benchmark-json` | Full benchmark results as JSON string | + +## License + +MIT diff --git a/packages/action/__tests__/action.test.ts b/packages/action/__tests__/action.test.ts new file mode 100644 index 000000000..b407c6ea7 --- /dev/null +++ b/packages/action/__tests__/action.test.ts @@ -0,0 +1,107 @@ +/*! node-minify action tests - MIT Licensed */ + +import { describe, expect, test } from "vitest"; +import { checkThresholds } from "../src/checks.ts"; +import type { ActionInputs, FileResult, MinifyResult } from "../src/types.ts"; + +describe("Action Types", () => { + test("ActionInputs has required fields", () => { + const inputs: ActionInputs = { + input: "src/app.js", + output: "dist/app.min.js", + compressor: "terser", + options: {}, + reportSummary: true, + reportPRComment: false, + reportAnnotations: false, + benchmark: false, + benchmarkCompressors: ["terser", "esbuild"], + failOnIncrease: false, + minReduction: 0, + includeGzip: true, + workingDirectory: ".", + }; + + expect(inputs.input).toBe("src/app.js"); + expect(inputs.compressor).toBe("terser"); + }); + + test("FileResult has all metrics", () => { + const result: FileResult = { + file: "app.js", + originalSize: 10000, + minifiedSize: 3000, + reduction: 70, + gzipSize: 1000, + timeMs: 50, + }; + + expect(result.reduction).toBe(70); + expect(result.gzipSize).toBe(1000); + }); + + test("MinifyResult aggregates file results", () => { + const result: MinifyResult = { + files: [ + { + file: "a.js", + originalSize: 5000, + minifiedSize: 1500, + reduction: 70, + timeMs: 25, + }, + { + file: "b.js", + originalSize: 5000, + minifiedSize: 1500, + reduction: 70, + timeMs: 25, + }, + ], + compressor: "terser", + totalOriginalSize: 10000, + totalMinifiedSize: 3000, + totalReduction: 70, + totalTimeMs: 50, + }; + + expect(result.files).toHaveLength(2); + expect(result.totalReduction).toBe(70); + }); +}); + +describe("Threshold Logic", () => { + test("should detect size increase", () => { + const reduction = -5; + const inputs = { + failOnIncrease: true, + minReduction: 0, + }; + + const error = checkThresholds(reduction, inputs); + expect(error).toContain("larger than original"); + expect(error).toContain("5.0% increase"); + }); + + test("should detect insufficient reduction", () => { + const reduction = 30; + const inputs = { + failOnIncrease: false, + minReduction: 50, + }; + + const error = checkThresholds(reduction, inputs); + expect(error).toContain("below minimum threshold"); + }); + + test("should pass when reduction meets threshold", () => { + const reduction = 60; + const inputs = { + failOnIncrease: false, + minReduction: 50, + }; + + const error = checkThresholds(reduction, inputs); + expect(error).toBeNull(); + }); +}); diff --git a/packages/action/__tests__/benchmark.test.ts b/packages/action/__tests__/benchmark.test.ts new file mode 100644 index 000000000..cb17df97c --- /dev/null +++ b/packages/action/__tests__/benchmark.test.ts @@ -0,0 +1,155 @@ +/*! node-minify action tests - MIT Licensed */ + +import { beforeEach, describe, expect, test, vi } from "vitest"; + +// Mock @node-minify/benchmark +vi.mock("@node-minify/benchmark", () => ({ + benchmark: vi.fn(), +})); + +import { benchmark } from "@node-minify/benchmark"; +import { runBenchmark } from "../src/benchmark.ts"; +import type { ActionInputs } from "../src/types.ts"; + +describe("runBenchmark", () => { + const baseInputs: ActionInputs = { + input: "src/app.js", + output: "dist/app.min.js", + compressor: "terser", + options: {}, + reportSummary: false, + reportPRComment: false, + reportAnnotations: false, + benchmark: true, + benchmarkCompressors: ["terser", "esbuild", "swc", "oxc"], + failOnIncrease: false, + minReduction: 0, + includeGzip: true, + workingDirectory: ".", + }; + + beforeEach(() => { + vi.resetAllMocks(); + }); + + test("filters out type-required compressors when type not provided", async () => { + vi.mocked(benchmark).mockResolvedValue({ + timestamp: new Date().toISOString(), + options: {}, + files: [ + { + file: "src/app.js", + originalSizeBytes: 10000, + originalSize: "10 KB", + results: [], + }, + ], + summary: { + bestCompression: "N/A", + bestPerformance: "N/A", + recommended: "N/A", + }, + }); + + await runBenchmark({ ...baseInputs, type: undefined }); + + expect(benchmark).toHaveBeenCalledWith( + expect.objectContaining({ + // esbuild should be filtered out (requires type) + compressors: ["terser", "swc", "oxc"], + }) + ); + }); + + test("includes all compressors when type is provided", async () => { + vi.mocked(benchmark).mockResolvedValue({ + timestamp: new Date().toISOString(), + options: {}, + files: [ + { + file: "src/app.js", + originalSizeBytes: 10000, + originalSize: "10 KB", + results: [], + }, + ], + summary: { + bestCompression: "N/A", + bestPerformance: "N/A", + recommended: "N/A", + }, + }); + + await runBenchmark({ ...baseInputs, type: "js" }); + + expect(benchmark).toHaveBeenCalledWith( + expect.objectContaining({ + compressors: ["terser", "esbuild", "swc", "oxc"], + }) + ); + }); + + test("maps benchmark results correctly", async () => { + vi.mocked(benchmark).mockResolvedValue({ + timestamp: new Date().toISOString(), + options: {}, + files: [ + { + file: "src/app.js", + originalSizeBytes: 10000, + originalSize: "10 KB", + results: [ + { + compressor: "terser", + success: true, + sizeBytes: 3000, + size: "3 KB", + reductionPercent: 70, + gzipBytes: 1200, + gzipSize: "1.2 KB", + timeMs: 150, + }, + ], + }, + ], + summary: { + bestCompression: "terser", + bestPerformance: "terser", + recommended: "terser", + }, + }); + + const result = await runBenchmark({ ...baseInputs, type: "js" }); + + expect(result.originalSize).toBe(10000); + expect(result.compressors[0]).toEqual({ + compressor: "terser", + success: true, + size: 3000, + reduction: 70, + gzipSize: 1200, + timeMs: 150, + error: undefined, + }); + expect(result.recommended).toBe("terser"); + expect(result.bestCompression).toBe("terser"); + expect(result.bestSpeed).toBe("terser"); + }); + + test("throws error when no file results", async () => { + vi.mocked(benchmark).mockResolvedValue({ + timestamp: new Date().toISOString(), + options: {}, + files: [], + summary: { + bestCompression: "N/A", + bestPerformance: "N/A", + recommended: "N/A", + }, + }); + + await expect(runBenchmark(baseInputs)).rejects.toThrow( + 'Benchmark failed: no results for input "src/app.js"' + ); + }); +}); diff --git a/packages/action/__tests__/inputs.test.ts b/packages/action/__tests__/inputs.test.ts new file mode 100644 index 000000000..b4b16ffe4 --- /dev/null +++ b/packages/action/__tests__/inputs.test.ts @@ -0,0 +1,267 @@ +/*! node-minify action tests - MIT Licensed */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +// Mock @actions/core before importing inputs +vi.mock("@actions/core", () => ({ + getInput: vi.fn(), + getBooleanInput: vi.fn(), + warning: vi.fn(), +})); + +// Mock @node-minify/utils +vi.mock("@node-minify/utils", () => ({ + isBuiltInCompressor: vi.fn(), +})); + +import { getBooleanInput, getInput, warning } from "@actions/core"; +import { isBuiltInCompressor } from "@node-minify/utils"; +import { parseInputs, validateCompressor } from "../src/inputs.ts"; + +describe("parseInputs", () => { + beforeEach(() => { + vi.resetAllMocks(); + // Default mocks + vi.mocked(getInput).mockImplementation((name: string) => { + const defaults: Record = { + input: "src/app.js", + output: "dist/app.min.js", + compressor: "terser", + options: "{}", + "min-reduction": "0", + "working-directory": ".", + }; + return defaults[name] || ""; + }); + vi.mocked(getBooleanInput).mockImplementation((name: string) => { + const defaults: Record = { + "report-summary": true, + "report-pr-comment": false, + "report-annotations": false, + benchmark: false, + "fail-on-increase": false, + "include-gzip": true, + }; + return defaults[name] ?? false; + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + test("parses basic inputs correctly", () => { + const inputs = parseInputs(); + + expect(inputs.input).toBe("src/app.js"); + expect(inputs.output).toBe("dist/app.min.js"); + expect(inputs.compressor).toBe("terser"); + expect(inputs.reportSummary).toBe(true); + }); + + test("throws error for invalid JSON in options", () => { + vi.mocked(getInput).mockImplementation((name: string) => { + if (name === "options") return "not-valid-json"; + if (name === "input") return "src/app.js"; + if (name === "output") return "dist/app.min.js"; + return ""; + }); + + expect(() => parseInputs()).toThrow("Invalid JSON in 'options' input"); + }); + + test("throws error when type required but not provided", () => { + vi.mocked(getInput).mockImplementation((name: string) => { + if (name === "compressor") return "esbuild"; + if (name === "type") return ""; + if (name === "input") return "src/app.js"; + if (name === "output") return "dist/app.min.js"; + return ""; + }); + + expect(() => parseInputs()).toThrow( + "Compressor 'esbuild' requires the 'type' input" + ); + }); + + test("throws error for invalid min-reduction value", () => { + vi.mocked(getInput).mockImplementation((name: string) => { + if (name === "min-reduction") return "abc"; + if (name === "input") return "src/app.js"; + if (name === "output") return "dist/app.min.js"; + return ""; + }); + + expect(() => parseInputs()).toThrow("Invalid 'min-reduction' input"); + }); + + test("throws error for out-of-range min-reduction", () => { + vi.mocked(getInput).mockImplementation((name: string) => { + if (name === "min-reduction") return "150"; + if (name === "input") return "src/app.js"; + if (name === "output") return "dist/app.min.js"; + return ""; + }); + + expect(() => parseInputs()).toThrow("Invalid 'min-reduction' input"); + }); + + test("parses benchmark compressors from comma-separated string", () => { + vi.mocked(getInput).mockImplementation((name: string) => { + if (name === "benchmark-compressors") return "terser, swc, oxc"; + if (name === "input") return "src/app.js"; + if (name === "output") return "dist/app.min.js"; + return ""; + }); + + const inputs = parseInputs(); + expect(inputs.benchmarkCompressors).toEqual(["terser", "swc", "oxc"]); + }); +}); + +describe("validateCompressor", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + test("warns for deprecated compressor", () => { + vi.mocked(isBuiltInCompressor).mockReturnValue(true); + + validateCompressor("babel-minify"); + + expect(warning).toHaveBeenCalledWith( + expect.stringContaining("Deprecated") + ); + }); + + test("warns for non-built-in compressor", () => { + vi.mocked(isBuiltInCompressor).mockReturnValue(false); + + validateCompressor("custom-compressor"); + + expect(warning).toHaveBeenCalledWith( + expect.stringContaining("not a built-in compressor") + ); + }); + + test("does not warn for valid built-in compressor", () => { + vi.mocked(isBuiltInCompressor).mockReturnValue(true); + + validateCompressor("terser"); + + expect(warning).not.toHaveBeenCalled(); + }); +}); + +describe("parseInputs edge cases", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getBooleanInput).mockImplementation((name: string) => { + const defaults: Record = { + "report-summary": true, + "report-pr-comment": false, + "report-annotations": false, + benchmark: false, + "fail-on-increase": false, + "include-gzip": true, + }; + return defaults[name] ?? false; + }); + }); + + test("throws error for invalid type value", () => { + vi.mocked(getInput).mockImplementation((name: string) => { + if (name === "type") return "invalid"; + if (name === "input") return "src/app.js"; + if (name === "output") return "dist/app.min.js"; + return ""; + }); + + expect(() => parseInputs()).toThrow( + "Invalid 'type' input: 'invalid' (expected 'js' or 'css')" + ); + }); + + test("accepts valid type 'js'", () => { + vi.mocked(getInput).mockImplementation((name: string) => { + if (name === "type") return "js"; + if (name === "input") return "src/app.js"; + if (name === "output") return "dist/app.min.js"; + return ""; + }); + + const inputs = parseInputs(); + expect(inputs.type).toBe("js"); + }); + + test("accepts valid type 'css'", () => { + vi.mocked(getInput).mockImplementation((name: string) => { + if (name === "type") return "css"; + if (name === "input") return "src/app.css"; + if (name === "output") return "dist/app.min.css"; + return ""; + }); + + const inputs = parseInputs(); + expect(inputs.type).toBe("css"); + }); + + test("deduplicates benchmark compressors", () => { + vi.mocked(getInput).mockImplementation((name: string) => { + if (name === "benchmark-compressors") + return "terser, terser, swc, swc"; + if (name === "input") return "src/app.js"; + if (name === "output") return "dist/app.min.js"; + return ""; + }); + + const inputs = parseInputs(); + expect(inputs.benchmarkCompressors).toEqual(["terser", "swc"]); + }); + + test("filters empty strings from benchmark compressors", () => { + vi.mocked(getInput).mockImplementation((name: string) => { + if (name === "benchmark-compressors") return "terser,,swc, ,oxc"; + if (name === "input") return "src/app.js"; + if (name === "output") return "dist/app.min.js"; + return ""; + }); + + const inputs = parseInputs(); + expect(inputs.benchmarkCompressors).toEqual(["terser", "swc", "oxc"]); + }); + + test("falls back to defaults when all benchmark compressors are empty", () => { + vi.mocked(getInput).mockImplementation((name: string) => { + if (name === "benchmark-compressors") return ", , ,"; + if (name === "input") return "src/app.js"; + if (name === "output") return "dist/app.min.js"; + return ""; + }); + + const inputs = parseInputs(); + expect(inputs.benchmarkCompressors).toEqual([ + "terser", + "esbuild", + "swc", + "oxc", + ]); + }); + + test("does not leak raw JSON in error message", () => { + vi.mocked(getInput).mockImplementation((name: string) => { + if (name === "options") return '{"secret": "password123'; + if (name === "input") return "src/app.js"; + if (name === "output") return "dist/app.min.js"; + return ""; + }); + + expect(() => parseInputs()).toThrow(/Invalid JSON in 'options' input/); + // Should NOT contain the actual input value + try { + parseInputs(); + } catch (err) { + expect((err as Error).message).not.toContain("password123"); + } + }); +}); diff --git a/packages/action/__tests__/outputs.test.ts b/packages/action/__tests__/outputs.test.ts new file mode 100644 index 000000000..8ad5bb4ef --- /dev/null +++ b/packages/action/__tests__/outputs.test.ts @@ -0,0 +1,150 @@ +/*! node-minify action tests - MIT Licensed */ + +import { beforeEach, describe, expect, test, vi } from "vitest"; + +// Mock @actions/core before importing +vi.mock("@actions/core", () => ({ + setOutput: vi.fn(), +})); + +import { setOutput } from "@actions/core"; +import { setBenchmarkOutputs, setMinifyOutputs } from "../src/outputs.ts"; +import type { BenchmarkResult, MinifyResult } from "../src/types.ts"; + +describe("setMinifyOutputs", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + test("sets all basic outputs", () => { + const result: MinifyResult = { + files: [ + { + file: "app.js", + originalSize: 10000, + minifiedSize: 3000, + reduction: 70, + timeMs: 50, + }, + ], + compressor: "terser", + totalOriginalSize: 10000, + totalMinifiedSize: 3000, + totalReduction: 70, + totalTimeMs: 50, + }; + + setMinifyOutputs(result); + + expect(setOutput).toHaveBeenCalledWith("original-size", 10000); + expect(setOutput).toHaveBeenCalledWith("minified-size", 3000); + expect(setOutput).toHaveBeenCalledWith("reduction-percent", "70.00"); + expect(setOutput).toHaveBeenCalledWith("time-ms", 50); + expect(setOutput).toHaveBeenCalledWith( + "report-json", + JSON.stringify(result) + ); + }); + + test("sets gzip-size when any file has gzipSize", () => { + const result: MinifyResult = { + files: [ + { + file: "a.js", + originalSize: 5000, + minifiedSize: 1500, + reduction: 70, + timeMs: 25, + }, + { + file: "b.js", + originalSize: 5000, + minifiedSize: 1500, + reduction: 70, + gzipSize: 800, + timeMs: 25, + }, + ], + compressor: "terser", + totalOriginalSize: 10000, + totalMinifiedSize: 3000, + totalReduction: 70, + totalTimeMs: 50, + }; + + setMinifyOutputs(result); + + expect(setOutput).toHaveBeenCalledWith("gzip-size", 800); + }); + + test("does not set gzip-size when no file has gzipSize", () => { + const result: MinifyResult = { + files: [ + { + file: "app.js", + originalSize: 10000, + minifiedSize: 3000, + reduction: 70, + timeMs: 50, + }, + ], + compressor: "terser", + totalOriginalSize: 10000, + totalMinifiedSize: 3000, + totalReduction: 70, + totalTimeMs: 50, + }; + + setMinifyOutputs(result); + + expect(setOutput).not.toHaveBeenCalledWith( + "gzip-size", + expect.anything() + ); + }); +}); + +describe("setBenchmarkOutputs", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + test("sets benchmark winner when recommended", () => { + const result: BenchmarkResult = { + file: "app.js", + originalSize: 10000, + compressors: [], + recommended: "terser", + bestCompression: "terser", + bestSpeed: "esbuild", + }; + + setBenchmarkOutputs(result); + + expect(setOutput).toHaveBeenCalledWith("benchmark-winner", "terser"); + expect(setOutput).toHaveBeenCalledWith("best-compression", "terser"); + expect(setOutput).toHaveBeenCalledWith("best-speed", "esbuild"); + }); + + test("does not set outputs when values are undefined", () => { + const result: BenchmarkResult = { + file: "app.js", + originalSize: 10000, + compressors: [], + recommended: undefined, + bestCompression: undefined, + bestSpeed: undefined, + }; + + setBenchmarkOutputs(result); + + expect(setOutput).not.toHaveBeenCalledWith( + "benchmark-winner", + expect.anything() + ); + expect(setOutput).toHaveBeenCalledWith( + "benchmark-json", + JSON.stringify(result) + ); + }); +}); diff --git a/packages/action/action.yml b/packages/action/action.yml new file mode 100644 index 000000000..b08b600c0 --- /dev/null +++ b/packages/action/action.yml @@ -0,0 +1,93 @@ +name: "node-minify" +description: "Minify JavaScript, CSS, and HTML files with detailed reporting and PR comments" +author: "srod" +branding: + icon: "minimize-2" + color: "green" + +inputs: + input: + description: "Files to minify (glob pattern or path)" + required: true + output: + description: "Output file path" + required: true + compressor: + description: | + Compressor to use. + Recommended: terser, esbuild, swc, oxc (fast, no Java). + Note: 'gcc' and 'yui' require Java (pre-installed on GitHub runners). + required: false + default: "terser" + type: + description: "File type: js or css (required for esbuild, yui)" + required: false + options: + description: "Compressor-specific options (JSON string)" + required: false + default: "{}" + report-summary: + description: "Add results to job summary" + required: false + default: "true" + report-pr-comment: + description: "Post results as PR comment" + required: false + default: "false" + report-annotations: + description: "Add file annotations for warnings" + required: false + default: "false" + benchmark: + description: "Run benchmark comparison across compressors" + required: false + default: "false" + benchmark-compressors: + description: "Compressors to compare (comma-separated)" + required: false + default: "terser,esbuild,swc,oxc" + fail-on-increase: + description: "Fail if minified size is larger than original" + required: false + default: "false" + min-reduction: + description: "Minimum reduction % required (0-100)" + required: false + default: "0" + include-gzip: + description: "Include gzip sizes in report" + required: false + default: "true" + working-directory: + description: "Working directory for file operations" + required: false + default: "." + github-token: + description: "GitHub token for PR comments" + required: false + +outputs: + original-size: + description: "Original file size in bytes" + minified-size: + description: "Minified file size in bytes" + reduction-percent: + description: "Size reduction percentage" + gzip-size: + description: "Gzipped size in bytes" + time-ms: + description: "Compression time in milliseconds" + report-json: + description: "Full report as JSON string" + benchmark-winner: + description: "Best compressor from benchmark (if run)" + best-compression: + description: "Compressor with best compression ratio (if benchmark run)" + best-speed: + description: "Fastest compressor (if benchmark run)" + benchmark-json: + description: "Full benchmark results as JSON string" + +runs: + using: "node20" + main: "dist/index.js" diff --git a/packages/action/package.json b/packages/action/package.json new file mode 100644 index 000000000..b252167c5 --- /dev/null +++ b/packages/action/package.json @@ -0,0 +1,67 @@ +{ + "name": "@node-minify/action", + "version": "10.3.0", + "description": "GitHub Action for node-minify - minify JS, CSS, HTML with detailed reporting", + "keywords": [ + "github-action", + "minify", + "minifier", + "compressor", + "ci-cd" + ], + "author": "Rodolphe Stoclin ", + "homepage": "https://github.com/srod/node-minify/tree/main/packages/action#readme", + "license": "MIT", + "private": true, + "type": "module", + "engines": { + "node": ">=20.0.0" + }, + "directories": { + "lib": "dist", + "test": "__tests__" + }, + "types": "./dist/index.d.ts", + "main": "./dist/index.js", + "exports": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "sideEffects": false, + "files": [ + "dist/**/*", + "action.yml" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/srod/node-minify.git" + }, + "bugs": { + "url": "https://github.com/srod/node-minify/issues" + }, + "scripts": { + "build": "bun build src/index.ts --outdir dist --target node --format esm --bundle --minify && cp src/index.d.ts dist/index.d.ts", + "build:ncc": "ncc build src/index.ts -o dist --source-map --license licenses.txt", + "format:check": "biome check .", + "lint": "biome lint .", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@actions/core": "^1.11.1", + "@actions/github": "^6.0.0", + "@node-minify/benchmark": "workspace:*", + "@node-minify/core": "workspace:*", + "@node-minify/esbuild": "workspace:*", + "@node-minify/oxc": "workspace:*", + "@node-minify/swc": "workspace:*", + "@node-minify/terser": "workspace:*", + "@node-minify/utils": "workspace:*" + }, + "devDependencies": { + "@node-minify/types": "workspace:*", + "@vercel/ncc": "^0.38.3" + } +} diff --git a/packages/action/src/benchmark.ts b/packages/action/src/benchmark.ts new file mode 100644 index 000000000..ed6336868 --- /dev/null +++ b/packages/action/src/benchmark.ts @@ -0,0 +1,76 @@ +/*! + * node-minify + * Copyright (c) 2011-2026 Rodolphe Stoclin + * MIT Licensed + */ + +import { resolve } from "node:path"; +import { benchmark } from "@node-minify/benchmark"; +import { TYPE_REQUIRED_COMPRESSORS } from "./inputs.ts"; +import type { ActionInputs, BenchmarkResult } from "./types.ts"; + +/** + * Run benchmark comparison across multiple compressors. + * + * Uses the @node-minify/benchmark package to run each compressor and + * converts the results to the action's BenchmarkResult format. + * + * @param inputs - Action inputs containing input file, benchmark compressors, and options + * @returns BenchmarkResult with per-compressor metrics and recommendations + */ +export async function runBenchmark( + inputs: ActionInputs +): Promise { + const inputPath = resolve(inputs.workingDirectory, inputs.input); + + // Filter out compressors that require 'type' when type is not provided + const compressors = inputs.type + ? inputs.benchmarkCompressors + : inputs.benchmarkCompressors.filter( + (c) => !TYPE_REQUIRED_COMPRESSORS.includes(c) + ); + + const result = await benchmark({ + input: inputPath, + compressors, + includeGzip: inputs.includeGzip, + type: inputs.type, + iterations: 1, + }); + + // Convert benchmark package result to action's BenchmarkResult type + // The benchmark package returns results per-file, we take the first file + const fileResult = result.files[0]; + if (!fileResult) { + throw new Error( + `Benchmark failed: no results for input "${inputs.input}". ` + + "Check that the file exists and at least one compressor succeeded." + ); + } + + return { + file: inputs.input, + originalSize: fileResult.originalSizeBytes, + compressors: fileResult.results.map((r) => ({ + compressor: r.compressor, + success: r.success, + size: r.sizeBytes, + reduction: r.reductionPercent, + gzipSize: r.gzipBytes, + timeMs: r.timeMs, + error: r.error, + })), + recommended: + result.summary.recommended !== "N/A" + ? result.summary.recommended + : undefined, + bestCompression: + result.summary.bestCompression !== "N/A" + ? result.summary.bestCompression + : undefined, + bestSpeed: + result.summary.bestPerformance !== "N/A" + ? result.summary.bestPerformance + : undefined, + }; +} diff --git a/packages/action/src/checks.ts b/packages/action/src/checks.ts new file mode 100644 index 000000000..952c879a9 --- /dev/null +++ b/packages/action/src/checks.ts @@ -0,0 +1,31 @@ +/*! + * node-minify + * Copyright (c) 2011-2026 Rodolphe Stoclin + * MIT Licensed + */ + +import type { ActionInputs } from "./types.ts"; + +/** + * Check whether a percent reduction violates configured thresholds. + * + * @param reduction - Percent change in size after minification (positive means size decreased; negative means size increased) + * @param inputs - Configuration with: + * - `failOnIncrease`: if true, treat any increase (negative `reduction`) as a violation + * - `minReduction`: minimum allowed reduction percentage; values below this are violations when > 0 + * @returns A human-readable error message describing the threshold violation, or `null` if no violation + */ +export function checkThresholds( + reduction: number, + inputs: Pick +): string | null { + if (inputs.failOnIncrease && reduction < 0) { + return `Minified size is larger than original (${Math.abs(reduction).toFixed(1)}% increase)`; + } + + if (inputs.minReduction > 0 && reduction < inputs.minReduction) { + return `Reduction ${reduction.toFixed(1)}% is below minimum threshold ${inputs.minReduction}%`; + } + + return null; +} diff --git a/packages/action/src/index.d.ts b/packages/action/src/index.d.ts new file mode 100644 index 000000000..7e2445891 --- /dev/null +++ b/packages/action/src/index.d.ts @@ -0,0 +1,7 @@ +/*! + * node-minify + * Copyright (c) 2011-2026 Rodolphe Stoclin + * MIT Licensed + */ + +export * from "./types.ts"; diff --git a/packages/action/src/index.ts b/packages/action/src/index.ts new file mode 100644 index 000000000..057edbbc7 --- /dev/null +++ b/packages/action/src/index.ts @@ -0,0 +1,86 @@ +/*! + * node-minify + * Copyright (c) 2011-2026 Rodolphe Stoclin + * MIT Licensed + */ + +import { info, setFailed } from "@actions/core"; +import { context } from "@actions/github"; +import { runBenchmark } from "./benchmark.ts"; +import { checkThresholds } from "./checks.ts"; +import { parseInputs, validateCompressor } from "./inputs.ts"; +import { runMinification } from "./minify.ts"; +import { setBenchmarkOutputs, setMinifyOutputs } from "./outputs.ts"; +import { addAnnotations } from "./reporters/annotations.ts"; +import { postPRComment } from "./reporters/comment.ts"; +import { + generateBenchmarkSummary, + generateSummary, +} from "./reporters/summary.ts"; + +/** + * Orchestrates the minification workflow for the GitHub Action. + * + * Parses and validates inputs, runs the minification, and persists outputs. + * Optionally generates a summary, posts a pull-request comment when running in a PR, and adds annotations based on inputs. + * If configured thresholds are violated or an error is thrown, signals action failure with an explanatory message. + */ +async function run(): Promise { + try { + const inputs = parseInputs(); + + validateCompressor(inputs.compressor); + + info(`Minifying ${inputs.input} with ${inputs.compressor}...`); + + const result = await runMinification(inputs); + + setMinifyOutputs(result); + + if (inputs.reportSummary) { + await generateSummary(result); + } + + if (inputs.reportPRComment && context.payload.pull_request) { + await postPRComment(result, inputs.githubToken); + } + + if (inputs.reportAnnotations) { + addAnnotations(result); + } + + if (inputs.benchmark) { + info( + `Running benchmark with compressors: ${inputs.benchmarkCompressors.join(", ")}...` + ); + const benchmarkResult = await runBenchmark(inputs); + setBenchmarkOutputs(benchmarkResult); + + if (inputs.reportSummary) { + await generateBenchmarkSummary(benchmarkResult); + } + + if (benchmarkResult.recommended) { + info(`🏆 Benchmark winner: ${benchmarkResult.recommended}`); + } + } + + const thresholdError = checkThresholds(result.totalReduction, inputs); + if (thresholdError) { + setFailed(thresholdError); + return; + } + + info( + `✅ Minification complete! ${result.totalReduction.toFixed(1)}% reduction in ${result.totalTimeMs}ms` + ); + } catch (error) { + if (error instanceof Error) { + setFailed(error.message); + } else { + setFailed("An unknown error occurred"); + } + } +} + +run(); diff --git a/packages/action/src/inputs.ts b/packages/action/src/inputs.ts new file mode 100644 index 000000000..87dd1573f --- /dev/null +++ b/packages/action/src/inputs.ts @@ -0,0 +1,140 @@ +/*! + * node-minify + * Copyright (c) 2011-2026 Rodolphe Stoclin + * MIT Licensed + */ + +import { getBooleanInput, getInput, warning } from "@actions/core"; +import { isBuiltInCompressor } from "@node-minify/utils"; +import type { ActionInputs } from "./types.ts"; + +const TYPE_REQUIRED_COMPRESSORS = ["esbuild", "yui"]; + +const DEPRECATED_COMPRESSORS: Record = { + "babel-minify": + "babel-minify only supports Babel 6 and is no longer maintained. Use 'terser' instead.", + "uglify-es": "uglify-es is no longer maintained. Use 'terser' instead.", + yui: "YUI Compressor was deprecated by Yahoo in 2013. Use 'terser' for JS or 'lightningcss' for CSS.", + crass: "crass is no longer maintained. Use 'lightningcss' or 'clean-css' instead.", + sqwish: "sqwish is no longer maintained. Use 'lightningcss' or 'clean-css' instead.", +}; + +/** + * Parse and validate GitHub Action inputs into an ActionInputs object. + * + * Reads inputs such as `input`, `output`, `compressor`, `type`, `options`, + * reporting flags, benchmarking settings, and other flags, applying defaults + * and validations (including JSON parsing for `options` and required `type` + * for certain compressors). + * + * @returns An object containing the parsed action inputs, including `input`, + * `output`, `compressor`, `type`, `options`, report flags, benchmark settings, + * `minReduction`, `includeGzip`, `workingDirectory`, and `githubToken`. + * + * @throws Error if a compressor that requires a `type` is selected but `type` + * is not provided. + * @throws Error if the `options` input is present but is not valid JSON. + * @throws Error if the `type` input is provided but is not 'js' or 'css'. + */ +export function parseInputs(): ActionInputs { + const compressor = getInput("compressor") || "terser"; + + // Validate type input explicitly + const typeRaw = getInput("type"); + let type: "js" | "css" | undefined; + if (typeRaw) { + if (typeRaw !== "js" && typeRaw !== "css") { + throw new Error( + `Invalid 'type' input: '${typeRaw}' (expected 'js' or 'css')` + ); + } + type = typeRaw; + } + + if (TYPE_REQUIRED_COMPRESSORS.includes(compressor) && !type) { + throw new Error( + `Compressor '${compressor}' requires the 'type' input (js or css)` + ); + } + + // Parse options JSON without leaking raw input in error messages + let options: Record = {}; + const optionsJson = getInput("options"); + if (optionsJson) { + try { + options = JSON.parse(optionsJson); + } catch (err) { + throw new Error( + `Invalid JSON in 'options' input: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + + // Parse benchmark compressors with deduplication and empty string filtering + const benchmarkCompressorsInput = getInput("benchmark-compressors"); + const benchmarkCompressors = (() => { + if (!benchmarkCompressorsInput) { + return ["terser", "esbuild", "swc", "oxc"]; + } + const parsed = benchmarkCompressorsInput + .split(",") + .map((c) => c.trim()) + .filter((c) => c.length > 0); + // Deduplicate while preserving order + const unique = [...new Set(parsed)]; + return unique.length > 0 ? unique : ["terser", "esbuild", "swc", "oxc"]; + })(); + + return { + input: getInput("input", { required: true }), + output: getInput("output", { required: true }), + compressor, + type, + options, + reportSummary: getBooleanInput("report-summary"), + reportPRComment: getBooleanInput("report-pr-comment"), + reportAnnotations: getBooleanInput("report-annotations"), + benchmark: getBooleanInput("benchmark"), + benchmarkCompressors, + failOnIncrease: getBooleanInput("fail-on-increase"), + minReduction: (() => { + const raw = getInput("min-reduction"); + if (!raw) return 0; + const value = Number.parseFloat(raw); + if (Number.isNaN(value) || value < 0 || value > 100) { + throw new Error( + `Invalid 'min-reduction' input: '${raw}' is not a valid number (expected 0-100)` + ); + } + return value; + })(), + includeGzip: getBooleanInput("include-gzip"), + workingDirectory: getInput("working-directory") || ".", + githubToken: getInput("github-token") || process.env.GITHUB_TOKEN, + }; +} + +/** + * Validates a compressor identifier and emits warnings for deprecated or non-built-in compressors. + * + * Emits a warning when the compressor is listed as deprecated and emits a separate warning + * when the compressor is not recognized as a built-in compressor (indicating it will be + * treated as a custom npm package or local file). + * + * @param compressor - The compressor name or identifier to validate (e.g., "terser", "esbuild", or a custom package) + */ +export function validateCompressor(compressor: string): void { + const deprecationMessage = DEPRECATED_COMPRESSORS[compressor]; + if (deprecationMessage) { + warning(`⚠️ Deprecated: ${deprecationMessage}`); + } + + if (!isBuiltInCompressor(compressor)) { + warning( + `Compressor '${compressor}' is not a built-in compressor. ` + + `Treating as custom npm package or local file.` + ); + } +} + +export { DEPRECATED_COMPRESSORS, TYPE_REQUIRED_COMPRESSORS }; diff --git a/packages/action/src/minify.ts b/packages/action/src/minify.ts new file mode 100644 index 000000000..972ed09fd --- /dev/null +++ b/packages/action/src/minify.ts @@ -0,0 +1,91 @@ +/*! + * node-minify + * Copyright (c) 2011-2026 Rodolphe Stoclin + * MIT Licensed + */ + +import { stat } from "node:fs/promises"; +import { resolve } from "node:path"; +import { minify } from "@node-minify/core"; +import { getFilesizeGzippedRaw, resolveCompressor } from "@node-minify/utils"; +import type { ActionInputs, FileResult, MinifyResult } from "./types.ts"; + +/** + * Get the size of a file in bytes. + * + * @param filePath - Path to the file + * @returns The file size in bytes + */ +async function getFileSize(filePath: string): Promise { + const stats = await stat(filePath); + return stats.size; +} + +/** + * Minifies a single input file according to the provided action inputs and returns summary metrics. + * + * Uses `inputs` to resolve input/output paths, run the selected compressor with optional type and options, + * and optionally computes gzipped size. The result includes per-file metrics and aggregated totals. + * + * @param inputs - Configuration for the minification run (input/output paths relative to `workingDirectory`, `compressor` selection, optional `type` and `options`, and `includeGzip` flag) + * @returns A `MinifyResult` containing: + * - `files`: an array with one `FileResult` (`file`, `originalSize`, `minifiedSize`, `reduction`, optional `gzipSize`, `timeMs`) + * - `compressor`: the human-readable compressor label + * - `totalOriginalSize`: original file size in bytes + * - `totalMinifiedSize`: minified file size in bytes + * - `totalReduction`: percentage reduction (0–100) + * - `totalTimeMs`: elapsed minification time in milliseconds + */ +export async function runMinification( + inputs: ActionInputs +): Promise { + const inputPath = resolve(inputs.workingDirectory, inputs.input); + const outputPath = resolve(inputs.workingDirectory, inputs.output); + + const originalSize = await getFileSize(inputPath); + const { compressor, label } = await resolveCompressor(inputs.compressor); + + const startTime = performance.now(); + + await minify({ + compressor, + input: inputPath, + output: outputPath, + ...(inputs.type && { type: inputs.type }), + ...(Object.keys(inputs.options).length > 0 && { + options: inputs.options, + }), + }); + + const endTime = performance.now(); + const timeMs = Math.round(endTime - startTime); + + const minifiedSize = await getFileSize(outputPath); + const reduction = + originalSize > 0 + ? ((originalSize - minifiedSize) / originalSize) * 100 + : 0; + + let gzipSize: number | undefined; + if (inputs.includeGzip) { + gzipSize = await getFilesizeGzippedRaw(outputPath); + } + + const fileResult: FileResult = { + file: inputs.input, + originalSize, + minifiedSize, + reduction, + gzipSize, + timeMs, + }; + + return { + files: [fileResult], + compressor: label, + totalOriginalSize: originalSize, + totalMinifiedSize: minifiedSize, + totalReduction: reduction, + totalTimeMs: timeMs, + }; +} diff --git a/packages/action/src/outputs.ts b/packages/action/src/outputs.ts new file mode 100644 index 000000000..27f908bad --- /dev/null +++ b/packages/action/src/outputs.ts @@ -0,0 +1,49 @@ +/*! + * node-minify + * Copyright (c) 2011-2026 Rodolphe Stoclin + * MIT Licensed + */ + +import { setOutput } from "@actions/core"; +import type { BenchmarkResult, MinifyResult } from "./types.ts"; + +/** + * Publishes minification metrics from a MinifyResult to GitHub Actions outputs. + * + * Sets the following outputs: `original-size`, `minified-size`, `reduction-percent` (formatted to two decimal places), `time-ms`, and `report-json`. If per-file gzip sizes are present, also sets `gzip-size` to the total gzip size across files. + * + * @param result - Minification summary containing total and per-file metrics used to populate action outputs + */ +export function setMinifyOutputs(result: MinifyResult): void { + setOutput("original-size", result.totalOriginalSize); + setOutput("minified-size", result.totalMinifiedSize); + setOutput("reduction-percent", result.totalReduction.toFixed(2)); + setOutput("time-ms", result.totalTimeMs); + setOutput("report-json", JSON.stringify(result)); + + if (result.files.some((f) => f.gzipSize !== undefined)) { + const totalGzip = result.files.reduce( + (sum, f) => sum + (f.gzipSize || 0), + 0 + ); + setOutput("gzip-size", totalGzip); + } +} + +/** + * Exposes benchmark metrics as GitHub Actions outputs. + * + * @param result - The benchmark result object whose properties are published as Action outputs. When present, `recommended` is written to `benchmark-winner`, `bestCompression` to `best-compression`, and `bestSpeed` to `best-speed`. The entire `result` is written as JSON to `benchmark-json`. + */ +export function setBenchmarkOutputs(result: BenchmarkResult): void { + if (result.recommended) { + setOutput("benchmark-winner", result.recommended); + } + if (result.bestCompression) { + setOutput("best-compression", result.bestCompression); + } + if (result.bestSpeed) { + setOutput("best-speed", result.bestSpeed); + } + setOutput("benchmark-json", JSON.stringify(result)); +} diff --git a/packages/action/src/reporters/annotations.ts b/packages/action/src/reporters/annotations.ts new file mode 100644 index 000000000..738783b10 --- /dev/null +++ b/packages/action/src/reporters/annotations.ts @@ -0,0 +1,55 @@ +/*! + * node-minify + * Copyright (c) 2011-2026 Rodolphe Stoclin + * MIT Licensed + */ + +import { error, notice, warning } from "@actions/core"; +import type { MinifyResult } from "../types.ts"; + +const LOW_REDUCTION_THRESHOLD = 20; +const VERY_LOW_REDUCTION_THRESHOLD = 5; + +/** + * Emit GitHub Actions annotations for each minified file based on its size reduction. + * + * For each file in `result.files` this reports an annotation scoped to that file: + * - An error if the minified file is larger than the original. + * - A warning if the reduction is very small, suggesting review for dead code or prior minification. + * - A notice if the reduction is low, suggesting the file may already be optimized. + * + * @param result - Minification result containing an array of file reports with reduction percentages and file paths + */ +export function addAnnotations(result: MinifyResult): void { + for (const file of result.files) { + if (file.reduction < 0) { + error( + `Minified file is larger than original (${Math.abs(file.reduction).toFixed(1)}% increase). ` + + `This may indicate an issue with the compressor settings.`, + { file: file.file } + ); + } else if (file.reduction < VERY_LOW_REDUCTION_THRESHOLD) { + warning( + `Very low compression ratio (${file.reduction.toFixed(1)}%). ` + + `Consider reviewing for dead code or checking if file is already minified.`, + { file: file.file } + ); + } else if (file.reduction < LOW_REDUCTION_THRESHOLD) { + notice( + `Low compression ratio (${file.reduction.toFixed(1)}%). ` + + `File may already be optimized.`, + { file: file.file } + ); + } + } +} + +/** + * Record an error annotation for a specific file indicating that minification failed. + * + * @param file - The file path to associate with the annotation + * @param errorMsg - A human-readable message describing the minification error + */ +export function addErrorAnnotation(file: string, errorMsg: string): void { + error(`Minification failed: ${errorMsg}`, { file }); +} diff --git a/packages/action/src/reporters/comment.ts b/packages/action/src/reporters/comment.ts new file mode 100644 index 000000000..576419701 --- /dev/null +++ b/packages/action/src/reporters/comment.ts @@ -0,0 +1,101 @@ +/*! + * node-minify + * Copyright (c) 2011-2026 Rodolphe Stoclin + * MIT Licensed + */ + +import { info, warning } from "@actions/core"; +import { context, getOctokit } from "@actions/github"; +import { prettyBytes } from "@node-minify/utils"; +import type { MinifyResult } from "../types.ts"; + +const COMMENT_TAG = ""; + +/** + * Posts or updates a pull request comment with a minification report. + * + * @param result - Minification results containing per-file metrics, totals, compressor name, and execution time + * @param githubToken - GitHub API token used to authenticate requests; when `undefined`, the function skips posting + */ +export async function postPRComment( + result: MinifyResult, + githubToken: string | undefined +): Promise { + if (!githubToken) { + warning("No GitHub token provided, skipping PR comment"); + return; + } + + const prNumber = context.payload.pull_request?.number; + if (!prNumber) { + warning("Not a pull request, skipping PR comment"); + return; + } + + const octokit = getOctokit(githubToken); + const { owner, repo } = context.repo; + + const body = generateCommentBody(result); + + const comments = await octokit.paginate(octokit.rest.issues.listComments, { + owner, + repo, + issue_number: prNumber, + }); + + const existingComment = comments.find((c) => c.body?.includes(COMMENT_TAG)); + + if (existingComment) { + await octokit.rest.issues.updateComment({ + owner, + repo, + comment_id: existingComment.id, + body, + }); + info(`Updated existing PR comment #${existingComment.id}`); + } else { + const { data: newComment } = await octokit.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body, + }); + info(`Created new PR comment #${newComment.id}`); + } +} + +/** + * Builds the Markdown body for the node-minify PR comment, including a per-file table, totals, and a configuration section. + * + * @param result - Minification results used to populate the report (expects `files`, `totalOriginalSize`, `totalMinifiedSize`, `totalReduction`, `compressor`, and `totalTimeMs`) + * @returns The Markdown string for the PR comment, beginning with `COMMENT_TAG` and containing the file table, total summary, and configuration details + */ +function generateCommentBody(result: MinifyResult): string { + const filesTable = result.files + .map( + (f) => + `| \`${f.file}\` | ${prettyBytes(f.originalSize)} | ${prettyBytes(f.minifiedSize)} | ${f.reduction.toFixed(1)}% |` + ) + .join("\n"); + + return `${COMMENT_TAG} +## 📦 node-minify Report + +| File | Original | Minified | Reduction | +|------|----------|----------|-----------| +${filesTable} + +**Total:** ${prettyBytes(result.totalOriginalSize)} → ${prettyBytes(result.totalMinifiedSize)} (${result.totalReduction.toFixed(1)}% reduction) + +
+Configuration + +- **Compressor:** ${result.compressor} +- **Time:** ${result.totalTimeMs}ms + +
+ +--- +*Generated by [node-minify](https://github.com/srod/node-minify) action* +`; +} diff --git a/packages/action/src/reporters/summary.ts b/packages/action/src/reporters/summary.ts new file mode 100644 index 000000000..114591e38 --- /dev/null +++ b/packages/action/src/reporters/summary.ts @@ -0,0 +1,108 @@ +/*! + * node-minify + * Copyright (c) 2011-2026 Rodolphe Stoclin + * MIT Licensed + */ + +import { summary } from "@actions/core"; +import { prettyBytes } from "@node-minify/utils"; +import type { BenchmarkResult, MinifyResult } from "../types.ts"; + +/** + * Generate a GitHub Actions summary reporting per-file minification metrics and totals. + * + * Builds a Markdown table with columns File, Original, Minified, Reduction, Gzip, and Time, + * includes the compressor name, and appends a total line showing aggregated original/minified sizes and overall reduction. + * + * @param result - Minification results containing per-file metrics and aggregate totals used to populate the summary + */ +export async function generateSummary(result: MinifyResult): Promise { + const rows = result.files.map((f) => [ + { data: `\`${f.file}\`` }, + { data: prettyBytes(f.originalSize) }, + { data: prettyBytes(f.minifiedSize) }, + { data: `${f.reduction.toFixed(1)}%` }, + { data: f.gzipSize ? prettyBytes(f.gzipSize) : "-" }, + { data: `${f.timeMs}ms` }, + ]); + + await summary + .addHeading("📦 node-minify Results", 2) + .addTable([ + [ + { data: "File", header: true }, + { data: "Original", header: true }, + { data: "Minified", header: true }, + { data: "Reduction", header: true }, + { data: "Gzip", header: true }, + { data: "Time", header: true }, + ], + ...rows, + ]) + .addBreak() + .addRaw(`**Compressor:** ${result.compressor}`) + .addBreak() + .addRaw( + `**Total:** ${prettyBytes(result.totalOriginalSize)} → ${prettyBytes(result.totalMinifiedSize)} (${result.totalReduction.toFixed(1)}% reduction)` + ) + .write(); +} + +/** + * Generate a Markdown benchmark summary comparing compressors and write it to the GitHub Actions summary. + * + * Builds a table of compressors showing status, size, reduction, gzip size, and time; marks recommended, best-speed, and best-compression entries with badges; and includes the source file and recommended compressor in the summary. + * + * @param result - BenchmarkResult containing the file path, originalSize, a list of compressors with their metrics or errors, and optional recommended/best markers used to annotate the table + */ +export async function generateBenchmarkSummary( + result: BenchmarkResult +): Promise { + const rows = result.compressors.map((c) => { + if (!c.success) { + return [ + { data: c.compressor }, + { data: "❌ Failed" }, + { data: "-" }, + { data: "-" }, + { data: c.error || "Unknown error" }, + ]; + } + const isRecommended = c.compressor === result.recommended; + const isBestSpeed = c.compressor === result.bestSpeed; + const isBestCompression = c.compressor === result.bestCompression; + + let badge = ""; + if (isRecommended) badge = " 🏆"; + else if (isBestSpeed) badge = " ⚡"; + else if (isBestCompression) badge = " 📦"; + + return [ + { data: `${c.compressor}${badge}` }, + { data: c.size != null ? prettyBytes(c.size) : "-" }, + { data: c.reduction != null ? `${c.reduction.toFixed(1)}%` : "-" }, + { data: c.gzipSize != null ? prettyBytes(c.gzipSize) : "-" }, + { data: c.timeMs != null ? `${c.timeMs}ms` : "-" }, + ]; + }); + + await summary + .addHeading("🏁 Benchmark Results", 2) + .addRaw( + `**File:** \`${result.file}\` (${prettyBytes(result.originalSize)})` + ) + .addBreak() + .addTable([ + [ + { data: "Compressor", header: true }, + { data: "Size", header: true }, + { data: "Reduction", header: true }, + { data: "Gzip", header: true }, + { data: "Time", header: true }, + ], + ...rows, + ]) + .addBreak() + .addRaw(`**Recommended:** ${result.recommended || "N/A"}`) + .write(); +} diff --git a/packages/action/src/types.ts b/packages/action/src/types.ts new file mode 100644 index 000000000..5117e0e26 --- /dev/null +++ b/packages/action/src/types.ts @@ -0,0 +1,74 @@ +/*! + * node-minify + * Copyright (c) 2011-2026 Rodolphe Stoclin + * MIT Licensed + */ + +export interface ActionInputs { + input: string; + output: string; + compressor: string; + /** + * File type hint for compressors that handle multiple types. + * Only required for `esbuild` (supports both JS and CSS) and deprecated `yui`. + * Other compressors auto-detect or only support one type. + */ + type?: "js" | "css"; + options: Record; + reportSummary: boolean; + reportPRComment: boolean; + reportAnnotations: boolean; + benchmark: boolean; + benchmarkCompressors: string[]; + failOnIncrease: boolean; + minReduction: number; + includeGzip: boolean; + workingDirectory: string; + githubToken?: string; +} + +export interface FileResult { + file: string; + originalSize: number; + minifiedSize: number; + reduction: number; + gzipSize?: number; + brotliSize?: number; + timeMs: number; +} + +export interface MinifyResult { + files: FileResult[]; + compressor: string; + totalOriginalSize: number; + totalMinifiedSize: number; + totalReduction: number; + totalTimeMs: number; +} + +export interface BenchmarkCompressorResult { + compressor: string; + success: boolean; + size?: number; + reduction?: number; + gzipSize?: number; + timeMs?: number; + error?: string; +} + +export interface BenchmarkResult { + file: string; + originalSize: number; + compressors: BenchmarkCompressorResult[]; + bestCompression?: string; + bestSpeed?: string; + recommended?: string; +} + +export interface ComparisonResult { + file: string; + baseSize: number | null; + currentSize: number; + change: number | null; + isNew: boolean; +} diff --git a/packages/action/tsconfig.json b/packages/action/tsconfig.json new file mode 100644 index 000000000..8ffe5db9f --- /dev/null +++ b/packages/action/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/action/vitest.config.ts b/packages/action/vitest.config.ts new file mode 100644 index 000000000..e5829d7aa --- /dev/null +++ b/packages/action/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineProject } from "vitest/config"; + +export default defineProject({ + test: { + testTimeout: 30000, + }, +}); diff --git a/packages/benchmark/README.md b/packages/benchmark/README.md index 4a1e6447f..1b143c5a7 100644 --- a/packages/benchmark/README.md +++ b/packages/benchmark/README.md @@ -31,7 +31,7 @@ import { benchmark } from '@node-minify/benchmark'; const results = await benchmark({ input: 'src/app.js', - compressors: ['terser', 'esbuild', 'swc'], + compressors: ['terser', 'esbuild', 'swc', 'oxc'], iterations: 3, includeGzip: true }); @@ -46,7 +46,7 @@ console.log(results.summary.recommended); // Best balance of speed and compressi node-minify benchmark src/app.js # Compare specific compressors -node-minify benchmark src/app.js --compressors terser,esbuild,swc +node-minify benchmark src/app.js --compressors terser,esbuild,swc,oxc # Custom compressors (npm packages or local files) node-minify benchmark src/app.js --compressors terser,./my-compressor.js,my-custom-pkg @@ -66,7 +66,7 @@ node-minify benchmark src/app.js -c terser,esbuild -f markdown | Option | CLI Flag | Description | Default | |--------|----------|-------------|---------| | `input` | `` | File(s) to benchmark | Required | -| `compressors` | `-c, --compressors` | Comma-separated list of compressors | `terser,esbuild,swc` | +| `compressors` | `-c, --compressors` | Comma-separated list of compressors | `terser,esbuild,swc,oxc` | | `iterations` | `-n, --iterations` | Number of iterations | `1` | | `format` | `-f, --format` | Output format: `console`, `json`, `markdown` | `console` | | `output` | `-o, --output` | Output file path | stdout | diff --git a/packages/benchmark/src/runner.ts b/packages/benchmark/src/runner.ts index 09969bfbd..31dce2d6b 100644 --- a/packages/benchmark/src/runner.ts +++ b/packages/benchmark/src/runner.ts @@ -9,6 +9,7 @@ import { minify } from "@node-minify/core"; import { getFilesizeBrotliInBytes, getFilesizeGzippedInBytes, + getFilesizeGzippedRaw, prettyBytes, wildcards, } from "@node-minify/utils"; @@ -53,6 +54,13 @@ export async function runBenchmark( }; } +/** + * Benchmarks a single input file using the configured compressors and returns per-compressor metrics. + * + * @param file - Path to the input file to benchmark + * @param options - Benchmark configuration (compressors to run, iterations, callbacks, and metric options) + * @returns A FileResult containing the file path, original size in bytes and human-readable form, and an array of CompressorMetrics for each attempted compressor + */ async function benchmarkFile( file: string, options: BenchmarkOptions @@ -62,7 +70,12 @@ async function benchmarkFile( const originalSize = prettyBytes(originalSizeBytes); const results: CompressorMetrics[] = []; - const compressors = options.compressors || ["terser", "esbuild", "swc"]; + const compressors = options.compressors || [ + "terser", + "esbuild", + "swc", + "oxc", + ]; for (const name of compressors) { if (options.onProgress) { @@ -114,7 +127,7 @@ async function benchmarkCompressor( compressor, input: file, output: warmupFile, - type: options.type as "js" | "css", + ...(options.type && { type: options.type as "js" | "css" }), options: options.compressorOptions, }); } @@ -130,7 +143,7 @@ async function benchmarkCompressor( compressor, input: file, output: lastOutputFile, - type: options.type as "js" | "css", + ...(options.type && { type: options.type as "js" | "css" }), options: options.compressorOptions, }); times.push(performance.now() - start); @@ -155,6 +168,7 @@ async function benchmarkCompressor( if (options.includeGzip) { metrics.gzipSize = await getFilesizeGzippedInBytes(lastOutputFile); + metrics.gzipBytes = await getFilesizeGzippedRaw(lastOutputFile); } if (options.includeBrotli) { diff --git a/packages/cli/__tests__/cli.test.ts b/packages/cli/__tests__/cli.test.ts index 32b171d14..537ac3056 100644 --- a/packages/cli/__tests__/cli.test.ts +++ b/packages/cli/__tests__/cli.test.ts @@ -86,7 +86,7 @@ describe("CSS compressors", () => { expect(spy).toHaveBeenCalled(); }); - test("should minify with lightningcss (requires type)", async () => { + test("should minify with lightningcss", async () => { const spy = vi.spyOn(cli, "run"); await cli.run({ compressor: "lightningcss", diff --git a/packages/cli/src/bin/cli.ts b/packages/cli/src/bin/cli.ts index eea2092f1..b8bbd352e 100644 --- a/packages/cli/src/bin/cli.ts +++ b/packages/cli/src/bin/cli.ts @@ -49,7 +49,7 @@ function setupProgram(): Command { .option("-o, --output [file]", "output file path") .option( "-t, --type [type]", - "file type: js or css (required for esbuild, lightningcss, yui)" + "file type: js or css (required for esbuild, yui)" ) .option("-s, --silence", "no output will be printed") .option( diff --git a/packages/utils/__tests__/utils.test.ts b/packages/utils/__tests__/utils.test.ts index 22889dd67..371991aed 100644 --- a/packages/utils/__tests__/utils.test.ts +++ b/packages/utils/__tests__/utils.test.ts @@ -25,6 +25,7 @@ import { getContentFromFiles, getFilesizeBrotliInBytes, getFilesizeGzippedInBytes, + getFilesizeGzippedRaw, getFilesizeInBytes, isValidFile, prettyBytes, @@ -514,6 +515,26 @@ describe("Package: utils", () => { }); }); + describe("getFilesizeGzippedRaw", () => { + test("should return file size in bytes", async () => { + const size = await getFilesizeGzippedRaw(fixtureFile); + expect(typeof size).toBe("number"); + expect(size).toBeGreaterThan(0); + }); + + test("should throw FileOperationError if file does not exist", async () => { + await expect(getFilesizeGzippedRaw("fake.js")).rejects.toThrow( + FileOperationError + ); + }); + + test("should throw FileOperationError if path is a directory", async () => { + await expect(getFilesizeGzippedRaw(__dirname)).rejects.toThrow( + FileOperationError + ); + }); + }); + describe("getFilesizeBrotliInBytes", () => { test("should return file size", async () => { const size = await getFilesizeBrotliInBytes(fixtureFile); diff --git a/packages/utils/src/compressor-resolver.ts b/packages/utils/src/compressor-resolver.ts index 71cb97225..db171c75c 100644 --- a/packages/utils/src/compressor-resolver.ts +++ b/packages/utils/src/compressor-resolver.ts @@ -29,6 +29,7 @@ const KNOWN_COMPRESSOR_EXPORTS: Record = { crass: "crass", sqwish: "sqwish", "html-minifier": "htmlMinifier", + "minify-html": "minifyHtml", jsonminify: "jsonMinify", imagemin: "imagemin", sharp: "sharp", diff --git a/packages/utils/src/getFilesizeGzippedInBytes.ts b/packages/utils/src/getFilesizeGzippedInBytes.ts index 348db1735..f481c2d09 100644 --- a/packages/utils/src/getFilesizeGzippedInBytes.ts +++ b/packages/utils/src/getFilesizeGzippedInBytes.ts @@ -4,48 +4,51 @@ * MIT Licensed */ -import { createReadStream, existsSync } from "node:fs"; +import { existsSync } from "node:fs"; import { FileOperationError } from "./error.ts"; import { isValidFile } from "./isValidFile.ts"; import { prettyBytes } from "./prettyBytes.ts"; /** - * Get the gzipped file size as a human-readable string. - * @param file - Path to the file - * @returns Formatted gzipped file size string (e.g., "1.5 kB") - * @throws {FileOperationError} If file doesn't exist or operation fails - * @example - * const size = await getFilesizeGzippedInBytes('file.js') - * console.log(size) // '1.5 kB' + * Compute the gzipped size of a file in bytes. + * + * @param file - Path to the file to measure + * @returns The gzipped size in bytes + * @throws FileOperationError if the file does not exist or the path is not a valid file + * @internal */ -export async function getFilesizeGzippedInBytes(file: string): Promise { - try { - if (!existsSync(file)) { - throw new FileOperationError( - "access", - file, - new Error("File does not exist") - ); - } - - if (!isValidFile(file)) { - throw new FileOperationError( - "access", - file, - new Error("Path is not a valid file") - ); - } +async function getGzipSize(file: string): Promise { + if (!existsSync(file)) { + throw new FileOperationError( + "access", + file, + new Error("File does not exist") + ); + } - const { gzipSizeStream } = await import("gzip-size"); - const source = createReadStream(file); + if (!isValidFile(file)) { + throw new FileOperationError( + "access", + file, + new Error("Path is not a valid file") + ); + } - const size = await new Promise((resolve, reject) => { - source - .pipe(gzipSizeStream()) - .on("gzip-size", resolve) - .on("error", reject); - }); + const { gzipSize } = await import("gzip-size"); + const { readFile } = await import("node:fs/promises"); + const content = await readFile(file); + return gzipSize(content); +} +/** + * Get the gzipped size of a file as a human-readable string. + * + * @param file - Path to the file + * @returns The gzipped size formatted for display (for example, "1.5 kB") + */ +export async function getFilesizeGzippedInBytes(file: string): Promise { + try { + const size = await getGzipSize(file); return prettyBytes(size); } catch (error) { throw new FileOperationError( @@ -55,3 +58,23 @@ export async function getFilesizeGzippedInBytes(file: string): Promise { ); } } + +/** + * Get the gzipped file size in bytes. + * @param file - Path to the file + * @returns Gzipped file size in bytes + * @example + * const bytes = await getFilesizeGzippedRaw('bundle.js') + * console.log(bytes) // 12583 + */ +export async function getFilesizeGzippedRaw(file: string): Promise { + try { + return await getGzipSize(file); + } catch (error) { + throw new FileOperationError( + "get gzipped size of", + file, + error as Error + ); + } +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index b04d3799d..aca28f76f 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -13,7 +13,10 @@ import { getContentFromFilesAsync, } from "./getContentFromFiles.ts"; import { getFilesizeBrotliInBytes } from "./getFilesizeBrotliInBytes.ts"; -import { getFilesizeGzippedInBytes } from "./getFilesizeGzippedInBytes.ts"; +import { + getFilesizeGzippedInBytes, + getFilesizeGzippedRaw, +} from "./getFilesizeGzippedInBytes.ts"; import { getFilesizeInBytes } from "./getFilesizeInBytes.ts"; import { isImageFile } from "./isImageFile.ts"; import { isValidFile, isValidFileAsync } from "./isValidFile.ts"; @@ -35,6 +38,7 @@ export { getContentFromFilesAsync, getFilesizeBrotliInBytes, getFilesizeGzippedInBytes, + getFilesizeGzippedRaw, getFilesizeInBytes, getKnownExportName, isBuiltInCompressor, diff --git a/scripts/check-published.ts b/scripts/check-published.ts index 92c4e3c37..96919616d 100644 --- a/scripts/check-published.ts +++ b/scripts/check-published.ts @@ -1,9 +1,14 @@ -import { execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; const PACKAGES_DIR = "packages"; +/** + * Lists package subdirectories under PACKAGES_DIR that contain a package.json file. + * + * @returns An array of directory names for packages that have a package.json in their folder + */ function getPackageDirs() { return readdirSync(PACKAGES_DIR, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) @@ -13,18 +18,35 @@ function getPackageDirs() { .map((entry) => entry.name); } -async function checkPublished(packageName: string, version: string) { +/** + * Checks whether an npm package exists on the registry and whether a specific version is published. + * + * @param packageName - The npm package name to check (e.g., "lodash") + * @param version - The package version to check for publication (e.g., "1.2.3") + * @returns An object with `exists`: `true` if the package is found on the npm registry, `false` otherwise; and `publishedVersion`: `true` if the specified `version` is published, `false` otherwise. + */ +function checkPublished(packageName: string, version: string) { + // Basic validation to prevent command injection + if (!/^[\w@/.-]+$/.test(packageName) || !/^[\w.-]+$/.test(version)) { + return { exists: false, publishedVersion: false }; + } + try { - const latest = execSync(`npm view ${packageName} version --json`, { - stdio: "pipe", - }) + const latest = execFileSync( + "npm", + ["view", packageName, "version", "--json"], + { + stdio: "pipe", + } + ) .toString() .trim(); if (latest === "" || latest === "undefined") return { exists: false, publishedVersion: false }; - const specific = execSync( - `npm view ${packageName}@${version} version --json`, + const specific = execFileSync( + "npm", + ["view", `${packageName}@${version}`, "version", "--json"], { stdio: "pipe" } ) .toString() @@ -38,6 +60,15 @@ async function checkPublished(packageName: string, version: string) { } } +/** + * Checks all non-private packages under the packages directory and reports their publication status on npm. + * + * Scans each package's package.json, queries npm to determine whether the package exists and whether the current + * package version is published, and prints categorized results to the console: + * - packages not found on npm (new packages) + * - packages with unpublished versions (updates needed) + * - or a confirmation that all packages are up to date + */ async function main() { const dirs = getPackageDirs(); const results = []; @@ -45,13 +76,20 @@ async function main() { console.log("Checking packages..."); for (const dir of dirs) { - const pkgPath = join(PACKAGES_DIR, dir, "package.json"); - const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + try { + const pkgPath = join(PACKAGES_DIR, dir, "package.json"); + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); - if (pkg.private) continue; + if (pkg.private) continue; - const status = await checkPublished(pkg.name, pkg.version); - results.push({ name: pkg.name, version: pkg.version, ...status }); + const status = checkPublished(pkg.name, pkg.version); + results.push({ name: pkg.name, version: pkg.version, ...status }); + } catch (error) { + console.warn( + `Skipping package ${dir}:`, + error instanceof Error ? error.message : String(error) + ); + } } const missing = results.filter((r) => !r.exists); @@ -78,4 +116,12 @@ async function main() { } } -main(); +main().catch((err) => { + if (err instanceof Error) { + console.error("Error checking packages:", err.message); + console.error(err.stack); + } else { + console.error("An unknown error occurred:", String(err)); + } + process.exit(1); +});