Skip to content

feat(rendering): minify rendered HTML behind FEATURE_FLAG_MINIFY_HTML - #36852

Open
fmontes wants to merge 24 commits into
mainfrom
issue-36851-native-html-minification
Open

feat(rendering): minify rendered HTML behind FEATURE_FLAG_MINIFY_HTML#36852
fmontes wants to merge 24 commits into
mainfrom
issue-36851-native-html-minification

Conversation

@fmontes

@fmontes fmontes commented Jul 31, 2026

Copy link
Copy Markdown
Member

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 the getPageHtml callers (PageResource REST and PageRenderDataFetcher GraphQL). This method already post-processes for CSP, so minification follows the established pattern.
  • FeatureFlagName — adds the FEATURE_FLAG_MINIFY_HTML constant.
  • 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 to response.getOutputStream() and writes into the static page cache — it never returns through getPageHtml. A servlet filter or a hook in VelocityServlet would 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:

  • Copies <pre>, <textarea>, <script>, and <style> content byte-for-byte — protects rendered output and JavaScript automatic semicolon insertion.
  • Copies quoted attribute values byte-for-byte, so <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.
  • Treats < 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.
  • Collapses whitespace between inline elements rather than removing it, so <span>a</span> <span>b</span> keeps its space and words are never joined. Whitespace bordering block elements is removed.
  • Collapses only the five characters HTML treats as collapsible whitespace (space, tab, LF, CR, FF). Unicode spaces that browsers render — the ideographic space U+3000 common in CJK copy, the thin space U+2009 — are left untouched. Character.isWhitespace is deliberately not used, since it matches those too.
  • Strips HTML comments but retains downlevel conditional comments (<!--[if IE]>).
  • Does not otherwise rewrite markup — no <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.
  • Degrades gracefully — any failure logs a warning and serves the original markup, so a bug here cannot take a page down.
  • Is idempotent, which matters because LIVE mode can minify on write and again through eval().

Checklist

  • Tests
  • Translations — n/a, no user-facing strings
  • Security Implications Contemplated — see notes below

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:

  • jsoup (already a dependency at 1.21.1) is a parser, not a minifier. It also injects <tbody> into tables written without one, which changes the DOM and breaks table > tr selectors 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.
  • htmlcompressor — the original com.googlecode.htmlcompressor is abandoned (last release 2011). The maintained fork com.github.hazendaz:htmlcompressor:2.0.2 is 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 SUCCESS without executing anything:

./mvnw test -pl :dotcms-core -Dmaven.build.cache.enabled=false -Dtest='HtmlMinifier*Test'
Tests run: 29, Failures: 0, Errors: 0, Skipped: 0

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: pre applied 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:

in     : <div style="white-space:pre">a\n  b</div>
actual : <div style="white-space:pre">a b</div>

<pre> and <textarea> are preserved byte-for-byte, but a styled div used 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-minify is 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_TAGS covers script, style, template, noscript and dialog, so whitespace beside them survives. It is keyed on the tag name, so it cannot see:

a <div hidden></div> b                      ->  a<div hidden></div>b                      "ab"
a <div style="display:none">x</div> b       ->  a<div style="display:none">x</div>b       "ab"

The hidden attribute is fixable (appendTag already parses attributes with quote tracking) but has not been done; display:none is the same CSS problem as (1). Both are far less common than the script/svg cases 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.

minifyBestEffort therefore 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:

Hello   <b>world</b>    ->  unchanged

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) call setContentType(CHARSET) with a charset rather than a media type, so a text/html check would disable the feature outright. A side effect worth its own issue: every rendered page currently ships Content-Type: UTF-8, and browsers are sniffing their way to the correct answer.

Screenshots

n/a — no UI changes.

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

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Claude finished @zJaaal's task in 3m 56s —— View job


Code Review — HTML minification

  • Read the diff and changed files
  • Recheck prior review findings
  • Analyze for new issues
  • Post review

I reviewed the full diff against origin/main: HtmlMinifier, both render seams, the flag, config, and the two test suites. This PR has already been through four adversarial rounds; below is a fresh pass focused on anything not yet raised.

New Issues

  • 🟡 Medium: dotCMS/src/main/java/com/dotcms/rendering/util/HtmlMinifier.java:535findPreserveTagEnd locates the closing tag with indexOfIgnoreCase(html, "</" + tag, …), which matches on a prefix. For a <script> element, a literal </scriptx> inside the body ends the preserved region early: the HTML tokenizer treats </scriptx as not an appropriate end tag (the char after script must be whitespace / / / >), so a browser keeps it as script data, but this scan closes preservation at </scriptx> and minifies whatever follows up to the real </script> as HTML. Same for </prefoo>, </stylex>, </textareax>. Failure case: <script>var re = /<\/scriptx>/;\n var y = 1;</script> — the var y = 1; run gets whitespace-collapsed as if outside the script. Very low frequency (requires a preserve tag name as a literal prefix inside its own body), which is why it's non-blocking, but it's a real divergence from browser tokenization. A boundary check on the character following the matched name would close it. Fix this →

Resolved

  • HtmlMinifier.java (literal >) — Copilot's isSignificantBefore finding is fixed; lastTag tracking replaces the output re-scan, and HtmlMinifierTest:197‑205 / literal-angle tests cover <p>Home > About</p> and 3 < 4.
  • HtmlMinifierTest:461 — Copilot's "enabled path never exercised" finding is fixed by test_minifyIfEnabled_respects_the_feature_flag, which toggles FEATURE_FLAG_MINIFY_HTML on and off and restores the original.
  • HtmlMinifier.java — invisible-element whitespace (script/style/template/noscript/dialog), the eleven missing inline tags, the conditional-comment dropped space, and the declaration-less XML / JSON-with-embedded-HTML escapes are all fixed and covered; verified against the current file.

Notes (non-blocking, already on record)

  • The looksLikeHtml heuristic and the Content-Type: UTF-8 observation are documented in the PR body as known limitations; the media-type signal is the correct long-term fix and is out of scope here.
  • VelocityLiveMode.writePage:295 correctly writes the minified output to the tee'd stream, so the page-cache entry stores the minified copy (minify-once-per-fill holds). CSP ordering in VelocityModeHandler.eval is preserved (apply() runs before minifyIfEnabled).

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 issue-36851-native-html-minification

@github-actions github-actions Bot added the Area : Backend PR changes Java/Maven backend code label Jul 31, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 HtmlMinifier to 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 into VelocityModeHandler.eval() (post-CSP processing path).
  • Adds FEATURE_FLAG_MINIFY_HTML and 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.

Comment thread dotCMS/src/main/java/com/dotcms/rendering/util/HtmlMinifier.java Outdated
@zJaaal zJaaal added the PR: docker image Build & push a per-PR test image to dotcms/dotcms-test label Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

🐳 PR Docker test image

Latest build for commit 0be584b pushed to dotcms/dotcms-test:

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>
Comment thread dotCMS/src/main/java/com/dotcms/rendering/util/HtmlMinifier.java
zJaaal and others added 2 commits August 5, 2026 13:26
…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>
@zJaaal

zJaaal commented Aug 5, 2026

Copy link
Copy Markdown
Member

Both review findings are addressed in 345b9788, plus a third case found while verifying them. Each has a test that fails against the previous implementation (Tests run: 19, Failures: 3 before, OK (19 tests) after).

1. Literal > read as a tag end (@Copilot)

Confirmed: <p>Home > About</p> came out as <p>Home >About</p>, because out.lastIndexOf("<") found the surrounding <p> and judged the space against a block element.

Rather than confirm that the last > closes the last tag, the scan now tracks the tag it emitted last as it goes, so the output buffer is never re-parsed:

// Name of the tag emitted last, or null when text was emitted last.
String lastTag = null;
...
private static boolean isSignificantBefore(final String lastTag) {
    return null == lastTag || isInlineTag(lastTag);
}

A literal > in text leaves lastTag null, so it reads as text and the space survives. A new isMarkupStart() gives the same treatment to a bare <, so <p>3 < 4</p> is also left alone. Side benefit: this removes an O(n) backward scan per whitespace run.

2. Attribute-value whitespace collapsed (claude[bot])

Confirmed: <input value="a b"> became <input value="a b">. Tags are now copied as a unit by a new appendTag() that tracks quoting, so attribute values survive byte-for-byte and a > inside a quoted value no longer ends the tag early. Whitespace between attributes is still collapsed to one space.

3. Unicode whitespace that HTML renders (found while verifying the above)

Character.isWhitespace matches characters HTML renders rather than collapses. Verified against the JDK that U+3000 (ideographic space, ordinary in CJK copy), U+2009, U+200A and U+2028/9 all return true, so they were being replaced by an ASCII space or dropped: <p>a b</p> became <p>a b</p>. Replaced with an isHtmlWhitespace() that matches only the five characters HTML treats as collapsible.

Also

The non-blocking 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.

Left deliberately alone: the space before a self-closing /. Dropping it would append the slash to an unquoted attribute value.

Test note

The ${net.bytebuddy:byte-buddy-agent:jar} surefire failure described above reproduces on a clean main checkout too, and -DargLine does not override it since the pom sets it. It blocks the whole dotcms-core unit suite locally, not just this class. I ran JUnit directly against the module classpath instead. Worth its own issue, but unrelated to this PR. CI is what should confirm the suite.

Investigated and written by Claude, posting under @zJaaal.

@mergify

mergify Bot commented Aug 5, 2026

Copy link
Copy Markdown

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

…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>
zJaaal and others added 2 commits August 5, 2026 16:47
…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>
@zJaaal
zJaaal requested a review from a team as a code owner August 5, 2026 20:04
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 fabrizzio-dotCMS left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. systemTableConfigSource is null until initSystemTableConfigSource() runs at boot, so in HtmlMinifierTest the expensive half of the path never executes.
  • Each read is two guaranteed cache misses. getSystemTableValue probes the system table for both DOT_FEATURE_FLAG_MINIFY_HTML and FEATURE_FLAG_MINIFY_HTML, neither of which exists unless someone set it via DB. The DOT_SYSTEM_CACHE_LOADED flag keeps that off the database, so it stays in memory — but if an operator adds the Redis provider to cache.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 in writePage() (isEnabled() and then again inside minifyIfEnabled()). 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)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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_TAGS gets the same free pass.
  2. Fixtures for every invisible tag and every replaced inline element.
  3. 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-minify addresses 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 fabrizzio-dotCMS left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@zJaaal

zJaaal commented Aug 10, 2026

Copy link
Copy Markdown
Member

@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 25a94ac9e1, verified red-then-green, corpus reduction unchanged at 28.1% and 46.3%.

Flag cost: taking the action, not the optimisation

Your measurement settles it — 179 ns against 256 us of minification, 0.14%. So no caching, and I agree a static final would be the wrong trade since it breaks the runtime flip the flag exists for.

But your second point is the actionable one, and it is now done: writePage() reads the flag once instead of twice.

// minifyBestEffort, not minifyIfEnabled, because the flag was already read above and reading
// it twice per render buys nothing.

minifyBestEffort keeps the best-effort Try for callers that have already checked. Two reasons it is worth the line, both yours: the two guaranteed system-table misses become network round trips per read if an operator adds a remote provider to cache.default.chain, and reading once closes the window where a flag flip between the reads buffers a whole page only to serve it unminified.

Also noting your point that this path cannot be measured in a unit test, because systemTableConfigSource is null until initSystemTableConfigSource() runs at boot — worth knowing before anyone tries to add a benchmark for it.

StringWriter: taken

final StringBuilderWriter merged = new StringBuilderWriter(MERGE_BUFFER_INITIAL_CAPACITY);

Confirmed commons-io is already imported in this file (TeeOutputStream, line 56), so no new dependency. Sized the buffer at 32 KB, which is where your numbers show the gap widening to 3.1x, since the synchronised StringBuffer takes the lock on each of Velocity's hundreds of small writes.

The jsoup <tbody> injection

Added to the library evaluation in the PR body. It is the strongest item on that list, because unlike the <html>/<body> injection it breaks table > tr selectors in CSS and JS that were previously working, and it would do so silently. Your framing is the right one: jsoup is the correct tool as a test oracle here, not as the implementation.

The CI note, which was affecting my own verification

This one mattered more than it looks. My PR body claimed the local surefire run failed on a byte-buddy agent property. With your invocation it runs fine:

./mvnw test -pl :dotcms-core -Dmaven.build.cache.enabled=false -Dtest='HtmlMinifier*Test'
Tests run: 29, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

I had been working around it by running JUnit directly against the module classpath, so the tests were real but the note in the PR body was wrong. Corrected there.

Not done

  • A corpus page with inline SVG icons (your suggestion Add .gitignore #3). Both corpus pages are demo-starter captures and neither contains an <svg>, so this needs a purpose-built page. Follow-up.
  • The white-space: pre escape hatch. Documented; I have asked on that thread which form you want, since it is a new operator-facing config surface rather than a bug fix. My preference is data-no-minify, because the failure is per-element and an operator needs to fix one page without disabling the feature globally.

Investigated and implemented by Claude, posting under @zJaaal.

…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>
@zJaaal

zJaaal commented Aug 10, 2026

Copy link
Copy Markdown
Member

@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 25a94ac9e1, reproduced first, then red-then-green.

  • Invisible elements and the 11 missing inline tags are now one idea rather than two: INVISIBLE_TAGS declared for its own reason, WHITESPACE_SIGNIFICANT_TAGS the union that the decision points consult, and isInlineTag renamed to keepsAdjacentWhitespace, which is what it was always asking. That covers template and noscript without having to locate their closing tags.
  • writePage() reads the flag once, StringWriterStringBuilderWriter with a sized buffer.
  • Corpus reduction unchanged at 28.1% / 46.3%.

The oracle gap, in 7df4ab7b2e. Your third comment was the one that mattered, and chasing it properly turned up more than the original two bugs.

  • The adjacency test is data driven from a specification list held in the test, not read from the implementation, plus a block-tag negative control. 63 tags × 8 layouts = 504 checks.
  • Writing those layouts exposed a bug in my own test: against the old code only 105 of the expected 120 combinations failed. The "beside inline markup" shape could never fail, because the intervening </b><b> left the separator non-empty whatever the minifier did. Fixed, all 8 shapes now have teeth.
  • Your suggestion Add .gitignore #3 is done, and it needed a second step I had not expected. icons-and-media.html is the first corpus entry built rather than captured — 45 inline SVG icons plus iframe/canvas/video/audio, none of which exist in either demo page. But adding it alone changed nothing: I checked, and the oracle is exactly as blind on the new page as on the captured ones. So test_minify_keeps_word_to_element_separations_in_real_pages counts word-to-element separations in the input and requires them in the output. 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 per your CI note — which also corrected the stale claim in the PR body about the local surefire failure.

Two decisions are yours, not code I want to guess at:

  1. The white-space: pre escape hatch. Documented in the PR body. On its thread I laid out three forms (configurable preserve-tag list, data-no-minify, whole-page opt-out) and argued for data-no-minify, since the failure is per-element and an operator needs to fix one page without disabling the feature globally. Your call on the form, and on whether it belongs here or in a follow-up.
  2. A corpus page is not a substitute for a browser. The oracle still cannot see CSS, so the limitation is documented rather than solved. If you think that is not good enough for merge, the honest answer is a real headless-browser check, which is a bigger piece of work than this PR.

Fair warning on CI: still in flight as I write this, and Postman Tests - Default has never completed a full run — the previous attempt aborted partway on a bug in my own assertion, so the LIVE cache requests, EDIT_MODE and the flag-off restore are still unexercised. Worth waiting for that job before signing off.

Written by Claude, posting under @zJaaal.

@fabrizzio-dotCMS fabrizzio-dotCMS left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread dotCMS/src/main/java/com/dotcms/rendering/util/HtmlMinifier.java
Comment thread dotCMS/src/main/java/com/dotcms/rendering/util/HtmlMinifier.java Outdated
…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 fabrizzio-dotCMS left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, h3h6, 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.

Comment thread dotCMS/src/main/java/com/dotcms/rendering/util/HtmlMinifier.java Outdated
Comment thread dotCMS/src/main/java/com/dotcms/rendering/util/HtmlMinifier.java Outdated
…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 fabrizzio-dotCMS left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 the charAt scanning 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback Area : Backend PR changes Java/Maven backend code PR: docker image Build & push a per-PR test image to dotcms/dotcms-test

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Native, configurable HTML minification in the core rendering engine

6 participants