yahyaerturan / minifier
HTML/JS/CSS minifier with template-awareness for PHP 8.4.
Requires
- php: ^8.4
- ext-json: *
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.89
- phpunit/phpunit: ^12.4.0
- symfony/finder: ^7.3
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A conservative, production-ready minifier for HTML with embedded CSS/JS and JSON. It’s template-aware (Mustache/Handlebars/Twig-style tokens), safe around </script> landmines, and intentionally avoids risky AST rewrites.
- Zero dependencies, PHP 8.4+
- Template fences:
{{ }},{{{ }}},{% %},{# #},[[ ]]are protected and restored - Robust
<script>handling: finds the real closing tag while skipping strings, comments, template literals, and CDATA - Sentinel escape:
</script>→<\/script>inside JS/JSON to prevent HTML parser bail-outs - Conservative JS/CSS: strips comments, collapses whitespace safely; preserves
/*! … */and comments with@license/@preserveverbatim - JSON scripts (
application/ld+json): minified viajson_decode/json_encodewith a safe fallback for not-strictly-valid inputs - Inline
style=""compacted using the same CSS minifier - Whitespace collapsed outside
pre/code/textarea/script/stylewithout stripping inter-tag spaces that can be visually significant (no global><gluing).
This library is for correctness first. If you want heavy optimization (renaming, dead-code elimination), use a separate build step and keep this as the final safety pass.
Installation
composer require yahyaerturan/minifier
Requires: PHP 8.4+
Quick start
use YahyaErturan\Minifier\HtmlMinifier; use YahyaErturan\Minifier\Options; $min = new HtmlMinifier(new Options()); // safe defaults $compact = $min->minify($html);
What you get by default:
- HTML comments removed except conditional
<!--[if …]>and bang<!--! … --> - Inline
<style>andstyle=""compacted <script>bodies minified for classic JS + modules,</script>escapedapplication/ld+jsoncompacted structurally- Templating tokens fenced and restored
Ignore controls (escape hatches)
Sometimes a third-party snippet or unusual markup breaks when minified. Use these “do not touch” controls.
HTML directives (comments)
-
Region pair:
<!-- minify:off --> … <!-- minify:on -->- The enclosed region is bypassed entirely (no minification, no sentinel escaping). The control comments themselves are removed.
-
One-shot:
<!-- minify:ignore-next --> <script>…</script><!-- minify:ignore-next --> <style>…</style>- The very next
<script>/<style>is output verbatim; control comment removed.
Per-tag attributes
- On
<script>or<style>:data-minify="off"→ do not minify the tag body. For<script>, the sentinel escape still applies for safety.data-minify="raw"→ output the tag verbatim (no minify, no sentinel escape). Sharp tool; use only when you truly need raw.
- The
data-minifyattribute is stripped from the output (build hint does not leak). - Unquoted attributes are supported:
type=module,data-minify=off|rawwork the same as quoted.
Options
final class Options { public function __construct( // HTML public bool $collapseHtmlWhitespace = true, public bool $removeHtmlComments = true, public bool $preserveBangHtmlComments = true, public bool $preserveConditionalComments = true, // Inline assets public bool $minifyInlineCss = true, public bool $minifyInlineJs = true, // JS public bool $removeJsComments = true, public bool $preserveImportantJsComments = true, // /*! … */ or @license/@preserve kept verbatim public bool $retainScriptSentinel = true, // keep /* <\/script> */ when removed comments had </script> // CSS public bool $removeCssComments = true, public bool $preserveImportantCssComments = true, // /*! … */ // JSON scripts public bool $minifyJsonScripts = true, // Template tokens public array $templateDelimiters = [ ['{{{','}}}'], ['{{','}}'], ['{%','%}'], ['{#','#}'], ['[[',']]'], ], // Ignore directives public bool $enableIgnoreDirectives = true, public string $ignoreOpenComment = 'minify:off', public string $ignoreCloseComment = 'minify:on', public string $ignoreNextComment = 'minify:ignore-next', public string $ignoreAttrName = 'data-minify', ) {} }
Notes:
retainScriptSentinel: when a removed JS comment contained</script>(pre-escaped), the minifier emits a tiny placeholder/* <\/script> */. Why: guarantees safety if another tool concatenates content later. Trade-off: a few bytes in rare cases.templateDelimiters: longest opener wins; add your own pairs if you use a different templating engine.- Ignore controls:
offkeeps<\/script>safety for<script>;rawis truly raw (no safety).
Script handling policy
Goal: Only minify when it’s definitely JavaScript; treat known template types as opaque; always keep </script> safe.
| Script type / condition | Action |
|---|---|
<script> with no type |
Minify JS + escape </script> |
type="module" (quoted or unquoted) |
Minify JS + escape </script> |
type is a recognized JS MIME: text/javascript, application/javascript, text/ecmascript, application/ecmascript, x-javascript |
Minify JS + escape </script> |
type="application/ld+json" or application/json |
JSON structural minify + escape </script> |
type is a template: text/template, text/x-template, text/handlebars, text/ng-template, application/x-tmpl |
Skip minification (opaque) |
data-minify="off" |
Do not minify; still escape </script> |
data-minify="raw" |
Output verbatim; no escaping |
Any other/unknown type |
Do not minify; do escape </script> (defensive default) |
Unquoted attribute support: HTML allows tokens without quotes; this minifier recognizes type=module and data-minify=off|raw even when unquoted.
Why conservative?
Pros: avoids broken pages, keeps template engines happy, plays well with CSP and weird embeds.
Cons: not byte-optimal; doesn’t rewrite semantics or identifiers.
Bottom line: use aggressive tools in CI if you need them; run this as your last line of defense in prod.
Framework integration (sketches)
Laravel middleware:
class MinifyHtml { public function handle($request, \Closure $next) { $response = $next($request); if (str_contains($response->headers->get('Content-Type', ''), 'text/html')) { $min = new \YahyaErturan\Minifier\HtmlMinifier(new \YahyaErturan\Minifier\Options()); $response->setContent($min->minify($response->getContent())); } return $response; } }
Symfony kernel response listener:
public function onKernelResponse(ResponseEvent $event): void { $res = $event->getResponse(); if (str_contains($res->headers->get('Content-Type', ''), 'text/html')) { $min = new \YahyaErturan\Minifier\HtmlMinifier(new \YahyaErturan\Minifier\Options()); $res->setContent($min->minify($res->getContent())); } }
Safety & security
- CSP: if you hash inline scripts/styles (
'sha256-…'), minify before computing hashes. - XSS: the minifier never unescapes entities or rewrites URLs; it only strips comments and collapses whitespace. It also neutralizes
</script>sequences with<\/script>to prevent HTML parser bail-out (unless you request raw). - Templates: tokens are fenced and restored verbatim. If you use exotic delimiters, add them to
templateDelimiters. - XSS/HTML parser:
</script>sequences inside JS/JSON are always neutralized as<\/script>. - Whitespace: The minifier compresses text nodes but intentionally keeps necessary spaces between HTML tags to avoid layout/text regressions.
Tests & QA
composer install
composer test
Recommended extras:
- Static analysis (PHPStan/Psalm)
- Mutation testing (Infection)
- Canary & monitor
Known limits (by design)
- No AST-level JS/CSS transforms (no mangling, dead-code removal, or re-ordering).
- No DOM reflow: the HTML pass doesn’t restructure tags or attributes.
- SVG/MathML treated as regular HTML except inside
<style>or attributes—safer default.
Versioning
Stable series: 1.0.x (patch releases are backwards-compatible).
License
MIT © Yahya Erturan