Skip to content

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

Open
fmontes wants to merge 1 commit into
mainfrom
issue-36851-native-html-minification
Open

feat(rendering): minify rendered HTML behind FEATURE_FLAG_MINIFY_HTML#36852
fmontes wants to merge 1 commit 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) — 11 test methods / 27 assertions covering the whitespace-significance 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.
  • 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.
  • 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. 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. 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. ./mvnw test -pl :dotcms-core currently fails in my local environment before reaching any test, on an unresolved ${net.bytebuddy:byte-buddy-agent:jar} surefire property. This is pre-existing and unrelated — an untouched FileUtilTest fails identically. I verified the suite by compiling and running it directly against the module classpath:

JUnit version 4.13.2
...........
OK (11 tests)

Worth confirming these run green in CI.

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 @fmontes's task in 1m 58s —— View job


Code Review — native HTML minification

  • Gather context
  • Review HtmlMinifier.java
  • Review integration seams (VelocityLiveMode, VelocityModeHandler)
  • Review FeatureFlagName + tests
  • Post findings

Nice work — the design (two seams, cache-fill-time minification, best-effort fallback, idempotency, preserved regions) is sound and well-documented. One real correctness bug stands out.

New Issues

  • 🟠 High: dotCMS/src/main/java/com/dotcms/rendering/util/HtmlMinifier.java:80-142Whitespace inside quoted attribute values is collapsed. minify() tracks no tag/attribute context; isSignificantBefore/isSignificantAfter only look at the immediately adjacent characters. Any whitespace run whose neighbors are not >/< is treated as significant inter-element text and collapsed to a single space — including whitespace inside an attribute value.

    Trace <input value="a b">: the a b run is preceded by a (≠ >, significant) and followed by b (≠ <, significant) → emitted as a b. Output becomes <input value="a b">.

    Impact when the flag is on:

    • <input type="hidden" value="two spaces"> → submitted form value silently changes.
    • data-config='{"key": "a b"}' and other JSON/data payloads → value changes, can break client JS.
    • alt/title/aria-label with intentional spacing → altered.
    • A newline inside an attribute value (VTL can emit these) is collapsed the same way.

    This is silent data corruption, not just cosmetic. The existing test test_minify_collapses_attribute_and_document_whitespace only covers whitespace between attributes (<div class="a" id="b">), so the case is untested. A minimal fix is to track whether the cursor is inside a tag and inside a quoted value, and copy attribute-value whitespace verbatim (and add a regression test).

    Fix this →

Notes (non-blocking)

  • HtmlMinifier.java:253-266 — the while loop in findPreserveTagEnd always returns on its first iteration (the trailing return html.length() after the loop is unreachable). Not a bug, but the loop reads as if it iterates over multiple close-tag candidates when it does not. Consider replacing with a straight-line lookup for clarity.
  • VelocityModeHandler.eval():111-113 — CSP-then-minify ordering is correct, and applying the minifier to preview/edit output is harmless. Good.
  • LIVE path (VelocityLiveMode.writePage:265-280) correctly minifies before the cache add(), so the cost is paid per cache fill, and the non-minify branch still streams directly. Good.

Everything else — feature-flag gating, Config.getBooleanProperty, Logger.warnAndDebug on failure returning original markup, preserved-region handling, conditional-comment retention — follows dotCMS conventions and looks correct.

• 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 on lines +150 to +164
private static boolean isSignificantBefore(final StringBuilder out) {

final int lastChar = out.length() - 1;
if (out.charAt(lastChar) != '>') {
// Preceded by text content.
return true;
}

final int tagStart = out.lastIndexOf("<");
if (tagStart < 0) {
return true;
}

return isInlineTag(out.substring(tagStart, out.length()));
}
Comment on lines +144 to +153
/**
* Given: null or empty input.
* Expected: it is returned untouched rather than throwing.
*/
@Test
public void test_minifyIfEnabled_handles_null_and_empty() {
assertEquals(null, HtmlMinifier.minifyIfEnabled(null));
assertEquals("", HtmlMinifier.minifyIfEnabled(""));
}
}
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

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Native, configurable HTML minification in the core rendering engine

3 participants