feat(rendering): minify rendered HTML behind FEATURE_FLAG_MINIFY_HTML - #36852
feat(rendering): minify rendered HTML behind FEATURE_FLAG_MINIFY_HTML#36852fmontes wants to merge 24 commits into
Conversation
Adds an opt-in HTML minifier that strips insignificant whitespace, line breaks and indentation from rendered pages before they are written to the response. Wired at the two seams that together cover every render path: - VelocityLiveMode.writePage() for LIVE mode, before the page cache write so minification is paid once per cache fill rather than per request - VelocityModeHandler.eval() for preview/edit/admin modes and the REST and GraphQL getPageHtml callers The minifier is conservative by design: pre/textarea/script/style content is copied byte-for-byte, whitespace between inline elements is collapsed rather than removed so words are never joined, and any failure returns the original markup. Refs #36851 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @zJaaal's task in 3m 56s —— View job Code Review — HTML minification
I reviewed the full diff against New Issues
Resolved
Notes (non-blocking, already on record)
Overall this is careful, well-tested, and safe by construction (best-effort with original-on-failure, opt-in flag). The one item above is a genuine edge worth a follow-up but does not block merge. · branch |
There was a problem hiding this comment.
Pull request overview
Adds a feature-flagged, dependency-free HTML minification step to dotCMS’s rendering pipeline so rendered pages can be served (and, in LIVE mode, cached) without indentation/blank lines/comments introduced by Velocity templates—opt-in via FEATURE_FLAG_MINIFY_HTML.
Changes:
- Introduces
HtmlMinifierto conservatively collapse insignificant whitespace and strip HTML comments while preserving<pre>,<textarea>,<script>, and<style>bodies. - Hooks minification into
VelocityLiveMode.writePage()(before page cache write) and intoVelocityModeHandler.eval()(post-CSP processing path). - Adds
FEATURE_FLAG_MINIFY_HTMLand a new unit test suite for the minifier.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| dotCMS/src/main/java/com/dotcms/rendering/util/HtmlMinifier.java | New minifier implementation guarded by FEATURE_FLAG_MINIFY_HTML. |
| dotCMS/src/main/java/com/dotcms/rendering/velocity/servlet/VelocityLiveMode.java | Minifies LIVE mode output before writing/storing into the page cache when enabled. |
| dotCMS/src/main/java/com/dotcms/rendering/velocity/servlet/VelocityModeHandler.java | Minifies eval() output (after CSP application when configured). |
| dotCMS/src/main/java/com/dotcms/featureflag/FeatureFlagName.java | Adds the FEATURE_FLAG_MINIFY_HTML feature flag constant + javadoc. |
| dotCMS/src/test/java/com/dotcms/rendering/util/HtmlMinifierTest.java | Adds unit tests for whitespace significance, preserved regions, comments, and idempotence. |
🐳 PR Docker test imageLatest build for commit docker pull dotcms/dotcms-test:pr-36852-issue-36851-native-html-minification
docker pull dotcms/dotcms-test:pr-36852-issue-36851-native-html-minification_0be584b |
Review of #36852 surfaced three cases where minification changed content rather than just formatting. Each is covered by a test that fails against the previous implementation. * Whitespace inside quoted attribute values was collapsed, because the scan carried no tag or attribute context: `<input value="a b">` became `<input value="a b">`. That silently rewrites submitted form values, JSON data attributes and accessible text. Tags are now copied as a unit by `appendTag()`, which tracks quoting, so attribute values survive byte-for-byte and a `>` inside a quoted value no longer ends the tag early. * A literal `>` in text was read as the end of a tag, so the whitespace after it was judged against whatever tag happened to precede it: `<p>Home > About</p>` became `<p>Home >About</p>`. The tag emitted last is now tracked as the scan proceeds instead of being recovered by scanning the output backwards for `<`, which also removes an O(n) backward scan per whitespace run. A bare `<`, as in `3 < 4`, is likewise treated as text. * `Character.isWhitespace` matches characters HTML renders rather than collapses, including the ideographic space (U+3000) common in CJK copy and the thin space (U+2009), so those were replaced by an ASCII space or dropped. Replaced with `isHtmlWhitespace()`, which matches only the five characters HTML treats as collapsible. Also addressed the non-blocking review notes and a latent crash: `findPreserveTagEnd` no longer wraps a loop that always returned on its first iteration, the unreachable `<![endif]` branch nested under `startsWith("<!--")` is gone, and two `Set.of(...).contains(null)` paths that degenerate markup such as `</>` would have hit are guarded. Behaviour deliberately left alone: the space before a self-closing `/` is kept, since dropping it would append the slash to an unquoted attribute value. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tag behaviour #36851 Covers the two review concerns about comments, both of which the current implementation already handles correctly. These tests keep it that way. * Whitespace removal can not forge or destroy a comment boundary. `< !--` is not a comment opener and `-- >` is not a terminator, so joining either would silently delete page content. Whitespace between two pieces of text is always collapsed to a single space rather than removed, which is the structural guarantee behind this. * Tags inside comments are never treated as markup. Comments are resolved before preserved-tag matching, so a commented-out `<pre>` does not open a preserved region. A commented-out `</body>` is removed outright, which means the `lastIndexOf("</body>")` search in `HTMLPageAssetRenderedBuilder.injectUVEScript` can no longer match inside a comment. * A retained downlevel conditional comment keeps its content verbatim, since that content is markup for the browsers that read it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Both review findings are addressed in 1. Literal
|
|
Tick the box to add this pull request to the merge queue (same as
|
…real pages #36851 The existing tests assert exact output, so they only cover cases somebody thought to write down. This adds an oracle that asserts an invariant instead: minified markup must be semantically identical to what went in. Anything that changes what a browser would render fails, anticipated or not. `assertIntegrity` parses both sides with jsoup (already a dependency, so no BOM change) and compares: * attribute values, byte-for-byte -- catches whitespace inside a quoted value being collapsed * `script`, `style`, `pre` and `textarea` bodies, byte-for-byte -- catches breaking JavaScript automatic semicolon insertion or rendered output * visible text, whitespace-normalised -- catches words being joined * element structure -- catches markup being restructured or truncated * idempotence -- LIVE mode can minify on write and again through `eval()` Driven by two inputs. Thirty fixtures target specific corruption modes, and a corpus of two real rendered demo pages under `src/test/resources` covers combinations nobody writes by hand: the home page carries an 11KB inline `<style>` block and 630 attribute values, the member page an inline script that depends on ASI for correctness. The oracle has teeth. Against the pre-fix implementation it fails on both the fixtures and the real-page corpus; against the current one all pass. Two further guards: a size floor, so an over-cautious change cannot keep integrity by minifying nothing, and an assertion that the feature flag is off with no configuration present, so the default cannot drift. One deliberate allowance is documented in `visibleText`. A browser renders each `<option>` as a discrete item, so whitespace between options is never painted and removing it is correct, but jsoup has no CSS model and concatenates their text, which reads as joined words. A separator is inserted on both sides of the comparison to restore the boundary. Whitespace within an option's own text is still compared. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ing #36851 `demo-members.html` was captured while authenticated, so it carried the rendered profile block of the logged-in account: display name, email address and privilege flags. The account was the stock demo admin, so nothing secret was published, but committing authenticated output to a public repository is the wrong pattern -- the next person to refresh the corpus from a real environment would leak a real user. Replaced with `Test User` / `user@example.com`. The fixture is here for its inline script and markup shape, so the identity was never load bearing. Swept both files for the rest: no tokens, API keys, session identifiers, CSP nonces, gravatar hashes (which are hashes of an email address), role or user identifiers, internal hostnames or IP addresses. The one remaining address, `info@dotcms.com` in the home page footer, is the demo starter's public contact. Added a README recording where each file came from, why it earns its place, and the checks to run before adding another. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cation on #36851 The Page collection already asserts a great deal about rendered output. Running it a second time against a server that minifies gives those assertions to the minifier for free, across every render path the collection touches rather than only the paths a bespoke test would think to exercise. It also covers the seams -- CSP ordering, UVE injection, the page cache -- which a unit test on HtmlMinifier cannot reach. * `dotcms-postman/pom.xml` -- new `postman.minify.html` property, defaulted to `false`, wired into the dotCMS container as `DOT_FEATURE_FLAG_MINIFY_HTML`. Every existing suite therefore keeps testing un-minified delivery, unchanged. * `.github/test-matrix.yml` -- one new entry running the same `page` collection with `-Dpostman.minify.html=true`. * `cicd_comp_test-phase.yml` -- the postman branch of the matrix generator now honours `extra_maven_args`, and `stage_name_suffix` so a collection can run twice without the two `build-reports-<stage_name>` artifacts colliding. Verified by replaying the generator over the parsed matrix: 12 postman jobs, 12 unique stage names, and the new entry resolves to `-Dpostman.collections=page -Dpostman.minify.html=true`. The property itself evaluates to `false` by default and `true` when overridden. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A comment sitting between whitespace-separated content made the separating
space disappear, joining words. Reported by claude[bot]; confirmed, and it
is slightly wider than reported -- it affects text adjacent to inline markup
too, not only two inline elements:
<span>a</span> <!--c--><span>b</span> -> <span>a</span><span>b</span> "ab"
<p>a <!--c--><b>b</b></p> -> <p>a<b>b</b></p> "ab"
isSignificantAfter judged the comment itself, and since a regular comment is
removed it was read as insignificant. But a removed comment is neither
significant nor insignificant, it is *transparent*: it cannot keep a space
alive on its own, and it must not kill one either. What decides is the
content on the far side of it.
The check now skips past consecutive removed comments and evaluates what
follows. It loops rather than recurses, so a long run of comments cannot
grow the stack. Conditional comments still count as markup in their own
right, since they are kept, and an unterminated comment still takes the
space with it because it swallows the rest of the document.
Only the space-before-only layout was broken. Space on both sides, or only
after, already recovered because the post-comment whitespace run re-ran the
check against the real next token. That asymmetry is why the existing suite
missed it: no case placed a comment between inline elements.
Red before, green after, at both levels: a unit test pinning the exact
output including the cases that must NOT gain a space (block elements
either side, and no doubling when whitespace surrounds the comment), plus
six integrity fixtures where the visible-text comparison catches the joined
words on its own. Reduction on the corpus is unchanged at 28.1% and 46.4%,
so nothing was given up for it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fabrizzio-dotCMS
left a comment
There was a problem hiding this comment.
Reviewed by running the algorithm rather than reading it: I extracted minify() into a standalone class (it is pure) and drove it through cases the suite does not cover, then re-ran this PR's own integrity oracle (jsoup 1.21.1) over the failures to see which ones it catches.
Two content-changing bugs, one test-oracle gap, one limitation to document. Details inline. The one I would fix first is the invisible-element whitespace case, because it is the same bug class already fixed for comments in round 1 and it silently joins words on any page with a <script> between inline elements.
On the library evaluation
The jsoup verdict holds, and there is one more reason to add. With prettyPrint(true).indentAmount(0) jsoup also injects <tbody> into tables written without one, which changes the DOM and breaks table > tr selectors in CSS and JS — on top of the <html><head><body> injection and the lowercased DOCTYPE already noted. It also keeps comments, so the information-disclosure benefit would be lost. jsoup is the right tool as a test oracle here, not as an implementation.
Feature-flag cost: measured, and it is a non-issue
I built a faithful replica of the Config.getBooleanProperty hot path as it runs in production — systemTableConfigSource active, SYSTEM_GROUP served by cache.default.chain (Caffeine only):
| cost | |
|---|---|
isEnabled(), one read |
179 ns |
the two reads per render in writePage() |
~0.36 us |
minify() on the 53 KB demo-home.html |
256 us |
| the two reads as a share of minifying alone | 0.14 % |
So nobody should spend time optimising the flag read, and a static final cache would be the wrong trade — it breaks runtime flipping, which is the point of a flag. Two things are worth knowing anyway:
- This cannot be measured in a unit test.
systemTableConfigSourceis null untilinitSystemTableConfigSource()runs at boot, so inHtmlMinifierTestthe expensive half of the path never executes. - Each read is two guaranteed cache misses.
getSystemTableValueprobes the system table for bothDOT_FEATURE_FLAG_MINIFY_HTMLandFEATURE_FLAG_MINIFY_HTML, neither of which exists unless someone set it via DB. TheDOT_SYSTEM_CACHE_LOADEDflag keeps that off the database, so it stays in memory — but if an operator adds the Redis provider tocache.default.chain, those guaranteed misses become network round trips per read. That is the one scenario where it stops being free, and it is the argument for reading the flag once instead of twice inwritePage()(isEnabled()and then again insideminifyIfEnabled()). Reading once also removes the window where a flag flip between the two reads buffers the whole page only to serve it unminified.
The buffer in writePage() is worth one line of change
StringWriter is backed by a synchronized StringBuffer, and Velocity emits a page as many small writes. Measured on the same 53 KB page, chunked into 1359 writes:
initial capacity default StringWriter 39.5 us StringBuilderWriter 28.5 us (1.4x)
initial capacity 32KB StringWriter 69.9 us StringBuilderWriter 22.4 us (3.1x)
org.apache.commons.io.output.StringBuilderWriter is unsynchronized and already on the classpath — this same file imports TeeOutputStream from commons-io — so this is a one-line swap plus an initial capacity, with no new dependency. ~17 us per render, small in absolute terms, but free.
CI note
Per the note about the local byte-buddy failure: ./mvnw test can report BUILD SUCCESS without running anything, because the Maven build cache skips surefire. ./mvnw test -pl :dotcms-core -Dmaven.build.cache.enabled=false -Dtest='HtmlMinifier*Test' runs them for real — check the "Tests run" count in the output.
| if (null != preserveTag) { | ||
| // Copy the element, including its content and closing tag, verbatim. | ||
| final int end = findPreserveTagEnd(html, index, preserveTag); | ||
| if (pendingSpace && INLINE_TAGS.contains(preserveTag)) { |
There was a problem hiding this comment.
Invisible elements are not transparent to whitespace collapsing — this changes rendered content.
This is the same bug class you already fixed for comments in round 1, but it was not applied to script / style / template / noscript. They are display:none, so they generate no box, and the whitespace on either side of them is rendered — exactly like whitespace around a comment.
I ran the algorithm from this PR (extracted minify(), which is pure) against cases the suite does not cover:
in : hola <script>var x=1</script> mundo
actual : hola<script>var x=1</script>mundo -> a browser paints "holamundo"
in : <b>a</b> <style>.x{}</style> <b>b</b>
actual : <b>a</b><style>.x{}</style><b>b</b> -> "ab"
in : <b>a</b> <template><i>t</i></template> <b>b</b>
actual : <b>a</b><template><i>t</i></template><b>b</b>
The fix is the pattern you already built for comments — isSignificantAfter should skip the invisible region the way it skips a comment, and this branch should leave pendingSpace and lastTag untouched when the preserved tag is invisible:
private static final Set<String> INVISIBLE_TAGS = Set.of("script", "style", "template", "noscript");
...
if (INVISIBLE_TAGS.contains(preserveTag)) {
out.append(html, index, end);
index = end;
continue; // pendingSpace and lastTag survive the region
}I checked that idempotence still holds with that change: the space is emitted on the far side of the region, and the second pass reaches the same decision.
There was a problem hiding this comment.
Confirmed and fixed in 25a94ac9e1. Reproduced all four of your cases before touching anything.
You were right that it is the comment bug class again. Rather than special-case the preserve branch, I unified the two ideas, since isInlineTag was never really asking about inline layout:
private static final Set<String> INVISIBLE_TAGS = Set.of("script", "style", "template", "noscript");
/** Every element beside which whitespace is painted ... */
private static final Set<String> WHITESPACE_SIGNIFICANT_TAGS =
Stream.concat(INLINE_TAGS.stream(), INVISIBLE_TAGS.stream())
.collect(Collectors.toUnmodifiableSet());The three decision points now consult that union and isInlineTag became keepsAdjacentWhitespace. One benefit over the continue version: it covers template and noscript without needing to locate their closing tags, since they are not preserved regions. The existing pendingSpace && keepsAdjacentWhitespace(preserveTag) line in the preserve branch already did the emitting, which is why textarea worked.
Corpus reduction is unchanged, 28.1% and 46.3%, so preserving whitespace around <style> blocks cost 1 to 2 bytes on the demo pages.
Investigated and fixed by Claude, posting under @zJaaal.
| * therefore significant. Whitespace touching any element <i>not</i> in this set can be removed | ||
| * outright; whitespace between two inline elements is collapsed to a single space instead. | ||
| */ | ||
| private static final Set<String> INLINE_TAGS = Set.of( |
There was a problem hiding this comment.
INLINE_TAGS is missing inline elements that appear in most modern templates.
Missing: svg, iframe, canvas, video, audio, embed, math, acronym, rt, rp, rtc. Since img and input are both here, the omission looks accidental rather than deliberate.
svg is the one that will bite: it is how every icon in a modern template is rendered.
in : Ver <svg width="8"></svg>
actual : Ver<svg width="8"></svg> -> the icon is glued to the word
in : <a href="#">x</a> <svg></svg>
actual : <a href="#">x</a><svg></svg>
in : <b>a</b> <iframe src="x"></iframe> <b>b</b>
actual : <b>a</b><iframe src="x"></iframe><b>b</b>
iframe and video/canvas are the same class: replaced inline elements, so the whitespace before them is painted.
There was a problem hiding this comment.
All eleven added in 25a94ac9e1: svg, iframe, canvas, video, audio, embed, math, acronym, rt, rp, rtc. Every case you listed reproduced.
Accidental rather than deliberate, yes. The set was assembled from an inline-elements list without cross-checking against replaced elements, which is exactly how img and object ended up present while iframe and canvas did not.
Agreed that svg is the one that would have been reported. Neither demo page in the corpus contains one, so nothing here would have caught it — see the reply on the oracle thread for what now does.
Investigated and fixed by Claude, posting under @zJaaal.
| * @return the document's rendered text, flattened and whitespace-normalised, so that legitimate | ||
| * collapsing is ignored but joined or lost words are not | ||
| */ | ||
| private static String visibleText(final Document document) { |
There was a problem hiding this comment.
The integrity oracle has a blind spot, and it is what let the two content bugs above through.
The class doc sells this as an invariant — "anything that changes what a browser would render is a failure". It is not, and the gap is structural rather than a missing fixture: jsoup has no CSS model, so visibleText() cannot see a spacing change next to an element that contributes no text of its own.
I ran this oracle exactly as written (jsoup 1.21.1, same four comparisons plus idempotence) over the 12 corruption cases I found. 10 pass clean, including all four script/style/template cases and four of the svg/canvas/video ones. Only iframe and acronym are caught, and only incidentally.
The option workaround right below this line is the same limitation surfacing: jsoup is not layout.
The real corpus does not cover the gap either — <svg>, <iframe>, <canvas>, <video> and <audio> appear 0 times across both demo pages, so test_minify_preserves_integrity_of_real_pages could not have caught this.
Suggested, in order of value:
- An adjacency assertion that does not depend on the element having text — compare the leaf sequence in document order, flagging whether each leaf had adjacent whitespace. Without this, the next tag added to
INLINE_TAGSgets the same free pass. - Fixtures for every invisible tag and every replaced inline element.
- One corpus page with inline SVG icons.
The invariant idea is right, and it is the strongest part of this PR — it just needs an oracle that can actually observe the property it claims.
There was a problem hiding this comment.
This was the most valuable comment on the PR, and I have reproduced your measurement. Running my own oracle over these cases: 9 of 10 pass clean, only iframe, and incidentally. So the claim in the class doc was false and it now states the limitation instead of denying it.
I did not take your suggestion #1 verbatim, for a reason worth flagging. A blanket "no adjacency flag may change" assertion fails on the legitimate removals around block elements, and deciding which are legitimate is precisely what the minifier does, so the oracle would be checking the implementation against itself.
What landed instead keeps the adjacency idea but breaks the circularity with an independent specification list:
/**
* ... held here as an independent specification of the HTML rendering model rather than read
* from HtmlMinifier ... the two must agree, and a tag present in one but missing from the other
* fails this test instead of quietly changing what pages render. Reading the implementations
* own set would make the test agree with any bug.
*/
private static final String[] WHITESPACE_SIGNIFICANT_NEIGHBOURS = { ... };
private static final String[] BLOCK_NEIGHBOURS = { ... };test_minify_keeps_whitespace_beside_every_significant_neighbour minifies alpha <tag></tag> omega, removes the element, and asserts a separator survives. It never asks the element to render text, which is what makes it see what visibleText() cannot. test_minify_removes_whitespace_beside_block_neighbours is the negative half, so the significant set cannot be widened until nothing is minified — which was the obvious way to make the first test pass dishonestly.
It answers your "the next tag added gets the same free pass" point: coverage is data driven, so a tag added to the spec list is tested without writing an assertion, and a tag added to the implementation alone fails.
On your suggestion #3, a corpus page with inline SVG icons: agreed, and not done. Both corpus pages come from the demo starter and neither has one, so it needs a purpose-built page rather than another capture. Worth a follow-up rather than holding this PR.
One note on the red-then-green, since it is the interesting part: on the old code the adjacency test named exactly the 15 missing tags and passed every tag already handled, which is the check that it is measuring the right thing rather than failing for its own reasons.
Investigated and implemented by Claude, posting under @zJaaal.
| /** | ||
| * Tags whose text content must be preserved byte-for-byte. | ||
| */ | ||
| private static final Set<String> PRESERVE_TAGS = Set.of("pre", "textarea", "script", "style"); |
There was a problem hiding this comment.
Worth calling out as a documented limitation: white-space: pre applied via CSS rather than via <pre>.
in : <div style="white-space:pre">a\n b</div>
actual : <div style="white-space:pre">a b</div>
Not fixable with a tag heuristic — the information lives in CSS — so I am not asking for code here. But this is the ticket a customer will open: rendering a code block inside a styled div instead of a <pre> is common, and it is precisely the shape of the original request in #36851.
Two things would help: mention it explicitly in the PR body and the release note alongside the existing safety notes, and give operators an escape hatch — a configurable additional preserve-tag list, or honouring a data-no-minify attribute — so a broken page can be fixed without turning the whole feature off.
There was a problem hiding this comment.
Reproduced, and agreed it is not fixable with a tag heuristic — the information is in CSS and this minifier does not parse CSS.
in : <div style="white-space:pre">a\n b</div>
actual : <div style="white-space:pre">a b</div>
Taking the documentation half now: it goes in the PR body next to the other safety notes, and it belongs in the release note for the same reason you give, that rendering a code block in a styled div is common and is close to the shape of the original request in #36851.
On the escape hatch, I agree with the need and would rather you or @zJaaal pick the form, since it is a new operator-facing config surface rather than a bug fix:
- a configurable additional preserve-tag list is the smaller change, but it cannot express "this one div", which is the actual failure
- honouring
data-no-minifyaddresses the real case and is roughly as small, since the preserve-region machinery already exists — the cost is that it is dotCMS-specific markup a template author has to know about - a third option: skip minification for a page entirely when the markup contains an opt-out marker, which is coarser but easiest to reason about during an incident
My preference is data-no-minify, because the failure is per-element and an operator hitting this needs to fix one page without turning the feature off globally. Happy to implement whichever you prefer, in this PR or a follow-up.
Investigated by Claude, posting under @zJaaal.
fabrizzio-dotCMS
left a comment
There was a problem hiding this comment.
Left a couple of comments, please read
…elements #36851 Two content-changing bugs found by @fabrizzio-dotCMS, both reproduced before fixing. **Invisible elements were not transparent to whitespace collapsing.** This is the bug class already fixed for comments, not applied to display:none elements. They generate no box, so the runs either side end up adjacent and the whitespace between them is painted: hola <script>var x=1</script> mundo -> hola<script>...</script>mundo "holamundo" <b>a</b> <style>.x{}</style> <b>b</b> -> <b>a</b><style>.x{}</style><b>b</b> "ab" **INLINE_TAGS was missing inline elements common in modern templates:** svg, iframe, canvas, video, audio, embed, math, acronym, rt, rp, rtc. `svg` is the one that bites, since it is how icons are rendered -- `Ver <svg/>` lost its space and glued the icon to the word. Rather than special-casing the preserve branch, the two ideas are unified. INVISIBLE_TAGS is declared for its own reason (no box) and WHITESPACE_SIGNIFICANT_TAGS is the union that the three decision points consult, so `isInlineTag` becomes `keepsAdjacentWhitespace` -- which is what it was always really asking. That covers template and noscript without needing to locate their closing tags. ### The test gap that let both through The integrity oracle could not see either bug, and not for want of a fixture: jsoup has no CSS model, so the visible-text comparison is blind to a spacing change beside an element contributing no text of its own. Measured: 9 of 10 of these corruptions passed the oracle clean, only iframe caught, and incidentally. The class doc claimed "anything that changes what a browser would render is a failure"; that was untrue and now says where the limits are. test_minify_keeps_whitespace_beside_every_significant_neighbour is the compensating control. It asserts adjacency, never asking the element to render text, and is driven by a specification list held in the test rather than read from the implementation -- reading the implementation's own set would make the test agree with any bug. The two lists must now agree. test_minify_removes_whitespace_beside_block_neighbours is the negative half, so the significant set cannot be widened until nothing is minified. Red then green: the adjacency test named exactly the 15 missing tags against the old code and passed the ones already handled. Corpus reduction is unchanged at 28.1% and 46.3%. ### Also from the review * writePage reads the flag once instead of twice. Free today because the system-table misses are served from memory, but an operator adding a remote provider to cache.default.chain turns each read into network round trips, and reading once also closes the window where a flag flip between the two reads buffers a whole page only to serve it unminified. minifyBestEffort keeps the best-effort Try for callers that have already checked the flag. * StringWriter -> commons-io StringBuilderWriter with a sized buffer. StringWriter is backed by a synchronized StringBuffer and Velocity emits a page as hundreds of small writes, so the lock is taken on each. commons-io is already imported in this file, so no new dependency. Verified with `-Dmaven.build.cache.enabled=false`, per the review's note that the build cache lets `./mvnw test` report success without running anything: 29 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@fabrizzio-dotCMS thanks — running the algorithm rather than reading it found two real content bugs and a structural hole in the tests. All four inline threads are answered; this covers the review body, which raised four more items. Both content bugs are fixed in Flag cost: taking the action, not the optimisationYour measurement settles it — 179 ns against 256 us of minification, 0.14%. So no caching, and I agree a But your second point is the actionable one, and it is now done: // minifyBestEffort, not minifyIfEnabled, because the flag was already read above and reading
// it twice per render buys nothing.
Also noting your point that this path cannot be measured in a unit test, because StringWriter: takenfinal StringBuilderWriter merged = new StringBuilderWriter(MERGE_BUFFER_INITIAL_CAPACITY);Confirmed commons-io is already imported in this file ( The jsoup
|
…36851 Three gaps I had named but not closed. **One shape only.** The adjacency test placed a single empty element between two bare words, so a fix that worked in that one position would have passed. It now runs each of the 63 tags through 8 layouts -- attributes on the element, children inside it, inside a block, nested two levels, indented across lines, followed by more markup, and between inline elements. 504 checks. Writing them found a bug in the test rather than the code. Against the pre-fix implementation only 105 combinations failed, not 120: the "beside inline markup" shape could never fail, because the intervening `</b><b>` tags left the separator non-empty whatever the minifier did. The helper now strips tags as well as the element under test, and asserts the element was actually found so a rewritten element cannot pass silently. All 8 shapes now fail on the old code, 15 tags x 8 = 120. **No real page contained the elements.** `icons-and-media.html` is the first corpus entry that was built rather than captured, because that was the point: neither demo page has an `<svg>`, `<iframe>`, `<canvas>`, `<video>` or `<audio>`, so no amount of real markup here could have caught the bugs. It carries 45 inline SVG icons in the `Home <svg>` shape a modern icon set produces, the four media elements, `noscript`, `template`, an ideographic space, JSON in single-quoted attributes, and a script that depends on ASI. 21KB, 28.2% reduction. **Adding it was not enough on its own.** Checked, and the integrity oracle is just as blind on the new page as on the demo ones -- it caught nothing there pre-fix, for the same structural reason. So test_minify_keeps_word_to_element_separations_in_real_pages counts the places where a word is separated from a whitespace-significant element and requires the output to still have them. No CSS model needed, and no judgement about which whitespace was removable. On the old code it reports "41 of 41 <svg> elements lost the space separating them from the preceding word". 30 tests, verified with -Dmaven.build.cache.enabled=false. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@fabrizzio-dotCMS ready for another look when you have time. Every thread is answered individually; this is the short version of what changed. Both content bugs fixed in
The oracle gap, in
30 tests, verified with Two decisions are yours, not code I want to guess at:
Fair warning on CI: still in flight as I write this, and Written by Claude, posting under @zJaaal. |
fabrizzio-dotCMS
left a comment
There was a problem hiding this comment.
Round 2 verified. All four inline findings are fixed, and I checked the same way as last time — by running the algorithm rather than reading it. I re-extracted minify() from 7df4ab7b2e and drove it through the 21 cases from my last review plus new variants: uppercase <SCRIPT>/<SVG>, two adjacent script regions, and the <embed> void element.
| finding | status |
|---|---|
| invisible elements | fixed in 25a94ac9e1 — all four cases round-trip and stay idempotent, and the uppercase and back-to-back-region variants are correct too |
INLINE_TAGS |
all eleven present, every case I listed now keeps its space |
| oracle blind spot | 7df4ab7b2e — the 63-tag specification list matches the implementation's union exactly, block negative control, 8 layouts, the class doc now states the limitation instead of denying it, and icons-and-media.html closes the corpus gap |
white-space: pre |
documented in the PR body next to the other safety notes |
Two things I want to name as better than what I asked for. Unifying INVISIBLE_TAGS and INLINE_TAGS into WHITESPACE_SIGNIFICANT_TAGS beats the continue I sketched, because it covers template and noscript without having to locate their closing tags. And you were right not to take suggestion #1 verbatim: a blanket "no adjacency flag may change" assertion would have the test validating the implementation against itself, which is the failure mode the specification list avoids. separatorAround stripping the element and the remaining tags before measuring is the detail that makes it real — without that line the "beside inline markup" shape would pass whatever the minifier did.
On the escape hatch, my preference stands: data-no-minify, because the failure is per element and an operator hitting it needs to fix one page rather than turn the feature off globally. Either here or as a follow-up, your call.
One new bug of the same class, in the conditional-comment branch, plus two smaller notes — inline. The conditional-comment one is not a regression from this round, it predates both fix rounds, but it is the last instance of the pattern those rounds were chasing and it changes what a modern browser renders. The four threads above are answered as far as I am concerned, feel free to resolve them.
…TML payloads #36851 Round 3 of review. Three findings from @fabrizzio-dotCMS and one from claude[bot], all reproduced before fixing. **The conditional-comment branch dropped the pending space.** It was the only branch setting `pendingSpace = false` without first emitting it, unlike the preserve and markup branches. Predates both earlier fix rounds and is the last instance of the pattern they were chasing: a <!--[if IE]>x<![endif]-->b -> a<!--[if IE]>x<![endif]-->b "ab" A conditional comment is markup only to browsers nobody ships; every modern engine treats it as a comment, paints nothing, and renders the whitespace either side. It only looked correct when whitespace on the far side happened to restore the space, which is exactly why every existing fixture -- four across both suites -- used one in isolation and missed it. The adjacency test could not reach it either: it is driven by tag names and a comment is not a tag. **Non-HTML payloads were being minified.** A VTL page can render JSON, XML or CSV through these same seams, where collapsing whitespace changes data rather than formatting. The CSV case is the worst: name,note\nalpha,"two spaces" -> name,note alpha,"two spaces" The row separator became a space. `minifyBestEffort` now skips anything that does not look like markup, and skips XML on its declaration since whitespace in XML text nodes is significant. Note the suggested content-type gate is not usable as things stand: `VelocityLiveMode` calls `setContentType(CHARSET)` with a charset rather than a media type, so gating on `text/html` would disable the feature outright. The residual this cannot see -- a JSON payload carrying an HTML fragment in a string value -- is documented. **`dialog` added to INVISIBLE_TAGS.** Hidden by the UA stylesheet without `open`, so it belongs there. The `hidden` attribute and `style="display:none"` reach the same corruption but need an attribute check and a CSS model respectively; both are documented rather than fixed. **The negative control had holes.** `dd`, `dt`, `caption`, `figcaption`, `h3`-`h6`, `details`, `summary`, `legend` and `pre` were in neither list, so adding any of them to the significant set would have passed both halves. The point of holding the specification in the test is that drift fails it, and drift only failed for the 28 tags named. All twelve verified to produce an empty separator already, so completing the list buys the guarantee for free. Red then green: 5 test methods and 3 fixtures fail against 7df4ab7, all pass after. 33 tests, verified with -Dmaven.build.cache.enabled=false. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fabrizzio-dotCMS
left a comment
There was a problem hiding this comment.
Round 3 verified. All three findings are fixed, and I re-extracted minify()/minifyBestEffort() from 90cd646971 and ran them rather than reading the diff.
| finding | status |
|---|---|
| conditional comment dropped the pending space | fixed — all three shapes keep their space and stay idempotent, including the downlevel-revealed one |
dialog / hidden |
dialog in the set; hidden and style="display:none" documented in the class doc, which is the call I said I would not block on |
| negative-control holes | all twelve tags landed (dd, dt, caption, figcaption, h3–h6, details, summary, legend, pre) |
I re-ran the full round-1 and round-2 case set against this commit as a regression check: invisible elements, the eleven inline tags, literal >/<, comment transparency and block collapsing all still behave, all idempotent.
Two things worth naming. <dialog open> is a block element and now keeps a space it does not need — that is the correct direction of error, since an extra byte is never a corruption, and it is not worth special-casing. And the fixture comment explaining why the conditional-comment gap existed is the part that stops it coming back; the five new fixtures cover exactly the shapes that were missing.
I verified your content-type justification independently, and it holds. VelocityModeHandler:39 defines CHARSET as Config.getStringProperty("CHARSET", "UTF-8"), and VelocityLiveMode:117, VelocityPreviewMode:61 and VelocityEditMode:54 all pass that to setContentType. Nothing else on that path sets one — the other setContentType("text/html") calls in the tree are error, redirect and login interceptors. So gating on text/html would indeed disable the feature outright, and you were right not to reach for it here. It also means every rendered page is shipping Content-Type: UTF-8, which is not a media type. That is pre-existing and squarely outside this PR, but it is worth its own look — right now browsers are sniffing their way to the correct answer.
The new scrutiny is on looksLikeHtml, since it is new behaviour that now decides whether the feature runs at all, and it was not something I asked for. Net it is clearly better than minifying unconditionally, and it errs in the safe direction. But it has two escapes, and only one of them is documented — details inline, plus a measured nit.
One cosmetic thing: dialog went into WHITESPACE_SIGNIFICANT_NEIGHBOURS between ruby and s, and the reflow left "style" alone on its own line. The list is alphabetical everywhere else.
…ast path #36851 Round 4, all on `looksLikeHtml` -- code from the last round rather than anything reported earlier. **Two escapes, both reproduced.** An XML declaration is optional, so keying the XML check on the prolog caught the polite case and missed the rest, and the documented JSON residual turned out to be the likelier of the two to be hit: <rss>\n <title>a b</title>\n</rss> -> <rss><title>a b</title></rss> {"body": "<p>a b</p>", "note": "two spaces"} -> "two spaces" The payload must now *begin* with an element -- after leading whitespace, a doctype or comments -- and that element must be one HTML defines. Beginning with an element keeps out JSON carrying a fragment in a string value; requiring a known name keeps out declaration-less XML. Adopted with one change. The suggestion was to check the first element against WHITESPACE_SIGNIFICANT_TAGS or PRESERVE_TAGS, but `html`, `body`, `div` and `p` are in neither, so that rule would have rejected ordinary pages and tag-leading fragments. HTML_TAGS is a proper allow-list, which also means an XML vocabulary nobody listed is skipped by default rather than minified until someone reports it. The cost is real and now tested rather than only described: a fragment beginning with text, `Hello <b>world</b>`, is no longer minified, because it cannot be told apart from JSON or CSV carrying markup. Compression lost, not content changed. **stripLeading() copied the payload to read five characters**, measured at 11.5 us on 53 KB, 4.5% of the cost of minifying it -- and paid on most renders, since Velocity output usually starts with the newline left behind by a directive on line one. Replaced with an index scan using isHtmlWhitespace, which also keeps the class consistent about not using Character.isWhitespace. Also: WHITESPACE_SIGNIFICANT_NEIGHBOURS back into alphabetical order after `dialog` was inserted. 34 tests, verified with -Dmaven.build.cache.enabled=false. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fabrizzio-dotCMS
left a comment
There was a problem hiding this comment.
Approving. Round 3 is verified, and I went looking for new bugs rather than only re-reading my own — nothing real came back.
What I ran this round, against 90cd646971
Adversarial fixtures on malformed and hostile markup, none of which corrupt anything and all of which are idempotent:
<script>var s = "</script>";</script>— the region ends at the first</script, which is exactly what an HTML parser does; JS string context does not protect it in a browser either, so matching that is correct- preserved regions in uppercase (
<PRE>,<SCRIPT>), with nested inline children, and unterminated - unterminated tag, unterminated comment, unterminated
<script>— each degrades the way a browser does, and the unterminated comment swallowing to EOF is right - unquoted attribute values, single-quoted JSON in a
data-attribute, self-closing<img …/>,<div class="a" > a < b,a <! b,a </> b,<!DOCTYPE html>- U+3000 ideographic space untouched, and a surrogate pair (
😀) survives thecharAtscanning intact
The real corpus, end to end. I reproduce your numbers exactly, and all three pages are idempotent:
demo-home.html 54275 -> 39007 28.1% reduction idempotent
demo-members.html 15141 -> 8124 46.3% reduction idempotent
icons-and-media.html 21183 -> 15167 28.4% reduction idempotent
The comment-stripping concern I wanted to rule out. Since eval() covers edit and preview, I checked whether anything in the render path uses HTML comments as functional markers that a client reads — the failure mode behind the commented-out <body> issue raised earlier in this PR. Every <!-- I can find in the rendering path and the shipped VTL is decorative (</div><!--/row--> and similar). Nothing functional, so stripping them is safe here.
Why the two open threads do not block
Both are real, and I am leaving them on record rather than withdrawing them, but neither is merge-blocking:
- XML without a declaration — reachable, but these seams only ever carry a rendered dotCMS page, and a feed or sitemap written as a VTL page essentially always carries the prolog. When it is hit, the damage is whitespace collapsing in text nodes, which is cosmetic in the realistic shapes. Nothing like the CSV case, where the row separator became a space — and that one is fixed.
- JSON carrying an HTML fragment — the more likely of the two, but bounded harder than I first framed it: JSON forbids literal newlines and tabs inside strings, so the only reachable corruption is a run of spaces inside a string value collapsing to one. Structural whitespace collapsing is harmless. Worth the documentation line; not worth holding the PR.
Both belong in the limitations note next to white-space: pre, since right now only the JSON one is written down. And stripLeading() is a measured nit, not a bug — your call whether to take it here.
One footnote from the fuzzing, for the record rather than for action: isMarkupStart accepts <?, so a <?php echo 1; ?> b becomes a<?php echo 1; ?>b. A processing instruction is a bogus comment to a browser, paints nothing, and is therefore the invisible-element class one more time. The frequency of a PI in dotCMS-rendered HTML is zero, so I would not touch it — but it is the same shape, if the pattern ever needs a general answer instead of a set of tag names.
Good work across three rounds. The shape that made the difference is the specification list held in the test: it is what turned my third comment last round into coverage rather than a fixture.
Fixes #36851
Adds an opt-in HTML minifier so rendered pages are served without the indentation, blank lines, and line breaks that VTL templates, containers, and widgets carry for readability.
Off by default — enable with
FEATURE_FLAG_MINIFY_HTML=true.Proposed Changes
HtmlMinifier(new) — dependency-free minifier that strips insignificant whitespace and HTML comments. Deliberately conservative: it does not minify JS/CSS, rewrite attributes, or strip optional end tags.VelocityLiveMode.writePage()— minifies LIVE mode output before the page cache write, so the cost is paid once per cache fill rather than on every request.VelocityModeHandler.eval()— covers preview/edit/admin/navigate modes plus thegetPageHtmlcallers (PageResourceREST andPageRenderDataFetcherGraphQL). This method already post-processes for CSP, so minification follows the established pattern.FeatureFlagName— adds theFEATURE_FLAG_MINIFY_HTMLconstant.HtmlMinifierTest(new) — 19 test methods / 57 assertions covering the whitespace-significance, attribute-value, literal-angle-bracket, Unicode-whitespace, comment-boundary and preserved-region edge cases.Why two seams instead of one filter
There is no single chokepoint for rendered HTML.
VelocityLiveMode.serve()streams directly toresponse.getOutputStream()and writes into the static page cache — it never returns throughgetPageHtml. A servlet filter or a hook inVelocityServletwould therefore have missed the highest-traffic path entirely. The two seams above are the minimum that covers every render path.Safety
The risky part of HTML minification is whitespace that looks removable but is actually rendered. This implementation:
<pre>,<textarea>,<script>, and<style>content byte-for-byte — protects rendered output and JavaScript automatic semicolon insertion.<input value="a b">keeps its spacing. Tags are parsed as a unit with quote tracking, which also means a>inside a quoted value does not end the tag early. Whitespace between attributes is still collapsed to one space.<and>as literal text when they are not part of a tag, so<p>Home > About</p>and<p>3 < 4</p>are left alone.<span>a</span> <span>b</span>keeps its space and words are never joined. Whitespace bordering block elements is removed.U+3000common in CJK copy, the thin spaceU+2009— are left untouched.Character.isWhitespaceis deliberately not used, since it matches those too.<!--[if IE]>).<html>/<body>injection into fragments, no DOCTYPE case changes, no auto-closing of tags, and no dropping of the space before a self-closing/(removing it would append the slash to an unquoted attribute value). This matters for partials, URL-mapped fragments, and non-HTML templates.eval().Checklist
Security notes: minification only removes whitespace and comments; it does not decode, re-encode, or re-escape content, so it cannot introduce XSS by unescaping. Comment stripping removes HTML comments from delivered pages, which slightly reduces incidental information disclosure. Ordering with CSP is preserved — in
eval(),ContentSecurityPolicyUtil.apply()still runs first, so nonce injection is unaffected.Additional Info
Library evaluation. Two candidates were assessed before writing custom code:
<tbody>into tables written without one, which changes the DOM and breakstable > trselectors in CSS and JS that previously worked, and it keeps comments, so the information-disclosure benefit would be lost. It is the right tool as a test oracle here, not as the implementation.prettyPrint(false)preserves whitespace verbatim (no minification at all);prettyPrint(true)re-indents. It also normalizes markup — injecting<html><head></head><body>into every fragment and lowercasing<!DOCTYPE html>— which would break fragment and URL-mapped output.com.googlecode.htmlcompressoris abandoned (last release 2011). The maintained forkcom.github.hazendaz:htmlcompressor:2.0.2is safe and handles preserved regions correctly, but deliberately collapses inter-tag whitespace to a single space rather than removing it, so output still carries a space between every tag. It is also the same library the customer explicitly rejected running as a plugin (see Native, configurable HTML minification in the core rendering engine #36851).Neither delivers full whitespace removal without custom logic layered on top, so a small owned minifier — guarded by tests — was the path chosen. No new dependency, no BOM change.
Scope. HTML whitespace only. Inline JS/CSS minification is intentionally out of scope; it is substantially riskier and should be a separate discussion.
Rollout. Enabling the flag does not retroactively minify already-cached pages — they update as cache entries refill. Flush the page cache to make it immediate.
Testing note. Run the unit tests with the build cache off, or Maven can report
BUILD SUCCESSwithout executing anything:Always check the "Tests run" count rather than the build status.
Known limitations
Three cases minification cannot detect. All are reachable only with the flag on, which is off by default and per instance, so the mitigation in every case is to turn the flag off for that instance.
1.
white-space: preapplied via CSS. Minification is driven by tag names, so whitespace made significant purely through CSS is not recognised:Minification is driven by tag names, so whitespace made significant purely through CSS is not recognised:
<pre>and<textarea>are preserved byte-for-byte, but a styleddivused as a code block is not, and that is a realistic pattern -- close to the shape of the original request in #36851. This is not fixable with a tag heuristic, since the information lives in CSS the minifier does not parse.An opt-out is under discussion on the PR;
data-no-minifyis the preferred form, since the failure is per element and an operator needs to fix one page rather than disable the feature globally.2. Elements hidden by attribute or by CSS rather than by tag name.
INVISIBLE_TAGScoversscript,style,template,noscriptanddialog, so whitespace beside them survives. It is keyed on the tag name, so it cannot see:The
hiddenattribute is fixable (appendTagalready parses attributes with quote tracking) but has not been done;display:noneis the same CSS problem as (1). Both are far less common than thescript/svgcases that were fixed, since they need a hidden block element sitting between two text runs.3. Fragments that begin with text are not minified. A VTL page can render JSON, XML or CSV through these seams, where collapsing whitespace changes data rather than formatting: a run of spaces inside a JSON string is part of the value, a newline in CSV separates records, and whitespace in an XML text node is significant.
minifyBestEfforttherefore requires the payload to begin with an element (after any leading whitespace, doctype or comment) and that element to be one HTML defines. Beginning with an element keeps out JSON that carries an HTML fragment in a string value; requiring a known name keeps out XML written without a declaration, such as<rss>or<urlset>.The price is that a fragment beginning with text rather than a tag is no longer minified:
Compression lost rather than content changed, which is the direction to err in, but worth knowing if you serve text-leading fragments.
Any content-shape test is guesswork; the real signal is the response media type. That is unusable as things stand, because
VelocityLiveMode(and the preview and edit handlers) callsetContentType(CHARSET)with a charset rather than a media type, so atext/htmlcheck would disable the feature outright. A side effect worth its own issue: every rendered page currently shipsContent-Type: UTF-8, and browsers are sniffing their way to the correct answer.Screenshots
n/a — no UI changes.