From a4aeea15919cbbe8cf27174819881c3b0890e3e6 Mon Sep 17 00:00:00 2001 From: Jeremie Bresson Date: Wed, 3 Dec 2025 09:24:48 +0100 Subject: [PATCH 01/33] [release] set version to "8.1.1-unblu-1" --- ph-css/pom.xml | 4 ++-- ph-csscompress-maven-plugin/pom.xml | 2 +- pom.xml | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/ph-css/pom.xml b/ph-css/pom.xml index 67182873..051f4a32 100644 --- a/ph-css/pom.xml +++ b/ph-css/pom.xml @@ -20,9 +20,9 @@ 4.0.0 - com.helger + unblu.patched.com.helger ph-css-parent-pom - 8.1.1-SNAPSHOT + 8.1.1-unblu-1 ph-css bundle diff --git a/ph-csscompress-maven-plugin/pom.xml b/ph-csscompress-maven-plugin/pom.xml index 902ca40a..d96261fc 100644 --- a/ph-csscompress-maven-plugin/pom.xml +++ b/ph-csscompress-maven-plugin/pom.xml @@ -22,7 +22,7 @@ com.helger ph-css-parent-pom - 8.1.1-SNAPSHOT + 8.1.1-unblu-1 com.helger.maven ph-csscompress-maven-plugin diff --git a/pom.xml b/pom.xml index 63239e1d..9f57fc6d 100644 --- a/pom.xml +++ b/pom.xml @@ -24,8 +24,9 @@ parent-pom 3.0.2 + unblu.patched.com.helger ph-css-parent-pom - 8.1.1-SNAPSHOT + 8.1.1-unblu-1 pom ph-css-parent-pom Base POM to build the ph-css projects From f8afd946125a3bd21bfb770fd5bf068b1c1520f9 Mon Sep 17 00:00:00 2001 From: Jeremie Bresson Date: Wed, 3 Dec 2025 09:25:41 +0100 Subject: [PATCH 02/33] Set version to "8.1.1-SNAPSHOT" --- ph-css/pom.xml | 2 +- ph-csscompress-maven-plugin/pom.xml | 2 +- pom.xml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ph-css/pom.xml b/ph-css/pom.xml index 051f4a32..35b87558 100644 --- a/ph-css/pom.xml +++ b/ph-css/pom.xml @@ -22,7 +22,7 @@ unblu.patched.com.helger ph-css-parent-pom - 8.1.1-unblu-1 + 8.1.1-SNAPSHOT ph-css bundle diff --git a/ph-csscompress-maven-plugin/pom.xml b/ph-csscompress-maven-plugin/pom.xml index d96261fc..902ca40a 100644 --- a/ph-csscompress-maven-plugin/pom.xml +++ b/ph-csscompress-maven-plugin/pom.xml @@ -22,7 +22,7 @@ com.helger ph-css-parent-pom - 8.1.1-unblu-1 + 8.1.1-SNAPSHOT com.helger.maven ph-csscompress-maven-plugin diff --git a/pom.xml b/pom.xml index 9f57fc6d..7fa56089 100644 --- a/pom.xml +++ b/pom.xml @@ -26,7 +26,7 @@ unblu.patched.com.helger ph-css-parent-pom - 8.1.1-unblu-1 + 8.1.1-SNAPSHOT pom ph-css-parent-pom Base POM to build the ph-css projects From 27f2d692545712f5eee8a3c32cd00c3204f72aff Mon Sep 17 00:00:00 2001 From: Jeremie Bresson Date: Tue, 7 Apr 2026 11:58:41 +0200 Subject: [PATCH 03/33] [release] set version to "8.1.2-unblu-1" --- ph-css/pom.xml | 2 +- ph-csscompress-maven-plugin/pom.xml | 8 ++++---- pom.xml | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ph-css/pom.xml b/ph-css/pom.xml index 2614d81c..3edfc1a7 100644 --- a/ph-css/pom.xml +++ b/ph-css/pom.xml @@ -22,7 +22,7 @@ unblu.patched.com.helger ph-css-parent-pom - 8.1.2-SNAPSHOT + 8.1.2-unblu-1 ph-css bundle diff --git a/ph-csscompress-maven-plugin/pom.xml b/ph-csscompress-maven-plugin/pom.xml index a8654738..cf3c3980 100644 --- a/ph-csscompress-maven-plugin/pom.xml +++ b/ph-csscompress-maven-plugin/pom.xml @@ -20,11 +20,10 @@ 4.0.0 - com.helger + unblu.patched.com.helger ph-css-parent-pom - 8.1.2-SNAPSHOT + 8.1.2-unblu-1 - com.helger.maven ph-csscompress-maven-plugin maven-plugin ph-csscompress-maven-plugin @@ -74,8 +73,9 @@ - com.helger + unblu.patched.com.helger ph-css + 8.1.2-unblu-1 diff --git a/pom.xml b/pom.xml index f30033ce..a33f1da7 100644 --- a/pom.xml +++ b/pom.xml @@ -26,7 +26,7 @@ unblu.patched.com.helger ph-css-parent-pom - 8.1.2-SNAPSHOT + 8.1.2-unblu-1 pom ph-css-parent-pom Base POM to build the ph-css projects From 5df49c10ae748764397b1a32bfa717e883fe1da0 Mon Sep 17 00:00:00 2001 From: Jeremie Bresson Date: Tue, 7 Apr 2026 15:07:42 +0200 Subject: [PATCH 04/33] Set version on "8.1.2-SNAPSHOT" --- ph-css/pom.xml | 2 +- ph-csscompress-maven-plugin/pom.xml | 4 ++-- pom.xml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ph-css/pom.xml b/ph-css/pom.xml index 3edfc1a7..2614d81c 100644 --- a/ph-css/pom.xml +++ b/ph-css/pom.xml @@ -22,7 +22,7 @@ unblu.patched.com.helger ph-css-parent-pom - 8.1.2-unblu-1 + 8.1.2-SNAPSHOT ph-css bundle diff --git a/ph-csscompress-maven-plugin/pom.xml b/ph-csscompress-maven-plugin/pom.xml index cf3c3980..e11ca7ba 100644 --- a/ph-csscompress-maven-plugin/pom.xml +++ b/ph-csscompress-maven-plugin/pom.xml @@ -22,7 +22,7 @@ unblu.patched.com.helger ph-css-parent-pom - 8.1.2-unblu-1 + 8.1.2-SNAPSHOT ph-csscompress-maven-plugin maven-plugin @@ -75,7 +75,7 @@ unblu.patched.com.helger ph-css - 8.1.2-unblu-1 + 8.1.2-SNAPSHOT diff --git a/pom.xml b/pom.xml index a33f1da7..f30033ce 100644 --- a/pom.xml +++ b/pom.xml @@ -26,7 +26,7 @@ unblu.patched.com.helger ph-css-parent-pom - 8.1.2-unblu-1 + 8.1.2-SNAPSHOT pom ph-css-parent-pom Base POM to build the ph-css projects From e66e9e1376d3e582e8472350c78688402e21df4a Mon Sep 17 00:00:00 2001 From: Jeremie Bresson Date: Tue, 7 Apr 2026 15:15:23 +0200 Subject: [PATCH 05/33] [release] set version to "8.1.2-unblu-2" --- ph-css/pom.xml | 2 +- ph-csscompress-maven-plugin/pom.xml | 4 ++-- pom.xml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ph-css/pom.xml b/ph-css/pom.xml index 2614d81c..aae8fd38 100644 --- a/ph-css/pom.xml +++ b/ph-css/pom.xml @@ -22,7 +22,7 @@ unblu.patched.com.helger ph-css-parent-pom - 8.1.2-SNAPSHOT + 8.1.2-unblu-2 ph-css bundle diff --git a/ph-csscompress-maven-plugin/pom.xml b/ph-csscompress-maven-plugin/pom.xml index e11ca7ba..c5f3bcd8 100644 --- a/ph-csscompress-maven-plugin/pom.xml +++ b/ph-csscompress-maven-plugin/pom.xml @@ -22,7 +22,7 @@ unblu.patched.com.helger ph-css-parent-pom - 8.1.2-SNAPSHOT + 8.1.2-unblu-2 ph-csscompress-maven-plugin maven-plugin @@ -75,7 +75,7 @@ unblu.patched.com.helger ph-css - 8.1.2-SNAPSHOT + 8.1.2-unblu-2 diff --git a/pom.xml b/pom.xml index f30033ce..28d3a52e 100644 --- a/pom.xml +++ b/pom.xml @@ -26,7 +26,7 @@ unblu.patched.com.helger ph-css-parent-pom - 8.1.2-SNAPSHOT + 8.1.2-unblu-2 pom ph-css-parent-pom Base POM to build the ph-css projects From 3a3e569c0bd5a636f82aa319d3fdc08ec7de28ce Mon Sep 17 00:00:00 2001 From: Jeremie Bresson Date: Tue, 7 Apr 2026 16:01:41 +0200 Subject: [PATCH 06/33] Set version on "8.1.2-SNAPSHOT" --- ph-css/pom.xml | 2 +- ph-csscompress-maven-plugin/pom.xml | 4 ++-- pom.xml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ph-css/pom.xml b/ph-css/pom.xml index aae8fd38..2614d81c 100644 --- a/ph-css/pom.xml +++ b/ph-css/pom.xml @@ -22,7 +22,7 @@ unblu.patched.com.helger ph-css-parent-pom - 8.1.2-unblu-2 + 8.1.2-SNAPSHOT ph-css bundle diff --git a/ph-csscompress-maven-plugin/pom.xml b/ph-csscompress-maven-plugin/pom.xml index c5f3bcd8..e11ca7ba 100644 --- a/ph-csscompress-maven-plugin/pom.xml +++ b/ph-csscompress-maven-plugin/pom.xml @@ -22,7 +22,7 @@ unblu.patched.com.helger ph-css-parent-pom - 8.1.2-unblu-2 + 8.1.2-SNAPSHOT ph-csscompress-maven-plugin maven-plugin @@ -75,7 +75,7 @@ unblu.patched.com.helger ph-css - 8.1.2-unblu-2 + 8.1.2-SNAPSHOT diff --git a/pom.xml b/pom.xml index 28d3a52e..f30033ce 100644 --- a/pom.xml +++ b/pom.xml @@ -26,7 +26,7 @@ unblu.patched.com.helger ph-css-parent-pom - 8.1.2-unblu-2 + 8.1.2-SNAPSHOT pom ph-css-parent-pom Base POM to build the ph-css projects From 3e77efa6f25eef7247ab1039f1234583cc217c49 Mon Sep 17 00:00:00 2001 From: Jeremie Bresson Date: Wed, 8 Apr 2026 11:27:34 +0200 Subject: [PATCH 07/33] [release] set version to "8.1.2-unblu-3" --- ph-css/pom.xml | 2 +- ph-csscompress-maven-plugin/pom.xml | 4 ++-- pom.xml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ph-css/pom.xml b/ph-css/pom.xml index 2614d81c..a30fd251 100644 --- a/ph-css/pom.xml +++ b/ph-css/pom.xml @@ -22,7 +22,7 @@ unblu.patched.com.helger ph-css-parent-pom - 8.1.2-SNAPSHOT + 8.1.2-unblu-3 ph-css bundle diff --git a/ph-csscompress-maven-plugin/pom.xml b/ph-csscompress-maven-plugin/pom.xml index e11ca7ba..3d8a5c5c 100644 --- a/ph-csscompress-maven-plugin/pom.xml +++ b/ph-csscompress-maven-plugin/pom.xml @@ -22,7 +22,7 @@ unblu.patched.com.helger ph-css-parent-pom - 8.1.2-SNAPSHOT + 8.1.2-unblu-3 ph-csscompress-maven-plugin maven-plugin @@ -75,7 +75,7 @@ unblu.patched.com.helger ph-css - 8.1.2-SNAPSHOT + 8.1.2-unblu-3 diff --git a/pom.xml b/pom.xml index f30033ce..7b833756 100644 --- a/pom.xml +++ b/pom.xml @@ -26,7 +26,7 @@ unblu.patched.com.helger ph-css-parent-pom - 8.1.2-SNAPSHOT + 8.1.2-unblu-3 pom ph-css-parent-pom Base POM to build the ph-css projects From b3004dcacd35510e009548fd5d68704b8a85d701 Mon Sep 17 00:00:00 2001 From: Jeremie Bresson Date: Wed, 8 Apr 2026 11:33:37 +0200 Subject: [PATCH 08/33] Set version on "8.1.2-SNAPSHOT" --- ph-css/pom.xml | 2 +- ph-csscompress-maven-plugin/pom.xml | 4 ++-- pom.xml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ph-css/pom.xml b/ph-css/pom.xml index a30fd251..2614d81c 100644 --- a/ph-css/pom.xml +++ b/ph-css/pom.xml @@ -22,7 +22,7 @@ unblu.patched.com.helger ph-css-parent-pom - 8.1.2-unblu-3 + 8.1.2-SNAPSHOT ph-css bundle diff --git a/ph-csscompress-maven-plugin/pom.xml b/ph-csscompress-maven-plugin/pom.xml index 3d8a5c5c..e11ca7ba 100644 --- a/ph-csscompress-maven-plugin/pom.xml +++ b/ph-csscompress-maven-plugin/pom.xml @@ -22,7 +22,7 @@ unblu.patched.com.helger ph-css-parent-pom - 8.1.2-unblu-3 + 8.1.2-SNAPSHOT ph-csscompress-maven-plugin maven-plugin @@ -75,7 +75,7 @@ unblu.patched.com.helger ph-css - 8.1.2-unblu-3 + 8.1.2-SNAPSHOT diff --git a/pom.xml b/pom.xml index 7b833756..f30033ce 100644 --- a/pom.xml +++ b/pom.xml @@ -26,7 +26,7 @@ unblu.patched.com.helger ph-css-parent-pom - 8.1.2-unblu-3 + 8.1.2-SNAPSHOT pom ph-css-parent-pom Base POM to build the ph-css projects From 223cbffd475eecf62f38352c1b3c179c19471848 Mon Sep 17 00:00:00 2001 From: "stanislav.galfy" Date: Tue, 25 Aug 2026 11:03:45 +0200 Subject: [PATCH 09/33] #18758 media range operators --- .../helger/css/decl/CSSMediaExpression.java | 218 ++++++++++++- .../css/decl/ECSSMediaRangeOperator.java | 71 +++++ .../css/handler/CSSNodeToDomainObject.java | 62 ++-- .../com/helger/css/handler/ECSSNodeType.java | 1 + ph-css/src/main/jjtree/ParserCSS30.jjt | 90 +++++- .../css/decl/CSSMediaExpressionTest.java | 286 ++++++++++++++++++ .../artificial/test-mediaqueries-range.css | 57 ++++ 7 files changed, 754 insertions(+), 31 deletions(-) create mode 100644 ph-css/src/main/java/com/helger/css/decl/ECSSMediaRangeOperator.java create mode 100644 ph-css/src/test/java/com/helger/css/decl/CSSMediaExpressionTest.java create mode 100644 ph-css/src/test/resources/testfiles/css30/good/artificial/test-mediaqueries-range.css diff --git a/ph-css/src/main/java/com/helger/css/decl/CSSMediaExpression.java b/ph-css/src/main/java/com/helger/css/decl/CSSMediaExpression.java index 1855999c..555b2bbb 100644 --- a/ph-css/src/main/java/com/helger/css/decl/CSSMediaExpression.java +++ b/ph-css/src/main/java/com/helger/css/decl/CSSMediaExpression.java @@ -34,37 +34,188 @@ import com.helger.css.media.ECSSMediaExpressionFeature; /** - * Represents a single media expression + * Represents a single media expression. Besides the classic (feature) and + * (feature: value) forms, the Media Queries Level 4 range context is supported: + * (feature >= value), (value <= feature) and + * (value <= feature <= value). + * + * @see Media Queries Level 4 - + * Range context */ @NotThreadSafe public class CSSMediaExpression implements ICSSWriteable, ICSSSourceLocationAware { + private final CSSExpression m_aRangeLeftValue; + private final ECSSMediaRangeOperator m_eRangeLeftOperator; private final String m_sFeature; + private final ECSSMediaRangeOperator m_eRangeRightOperator; private final CSSExpression m_aValue; private CSSSourceLocation m_aSourceLocation; + /** + * Constructor for a feature only expression like (color). + * + * @param eFeature + * The media feature. May not be null. + */ public CSSMediaExpression (@NonNull final ECSSMediaExpressionFeature eFeature) { this (eFeature.getName ()); } + /** + * Constructor for a feature only expression like (color). + * + * @param sFeature + * The media feature name. May neither be null nor empty. + */ public CSSMediaExpression (@NonNull @Nonempty final String sFeature) { this (sFeature, null); } + /** + * Constructor for a feature with value like (min-width: 600px). + * + * @param eFeature + * The media feature. May not be null. + * @param aValue + * The value of the feature. May be null. + */ public CSSMediaExpression (@NonNull final ECSSMediaExpressionFeature eFeature, @Nullable final CSSExpression aValue) { this (eFeature.getName (), aValue); } + /** + * Constructor for a feature with value like (min-width: 600px). + * + * @param sFeature + * The media feature name. May neither be null nor empty. + * @param aValue + * The value of the feature. May be null. + */ public CSSMediaExpression (@NonNull @Nonempty final String sFeature, @Nullable final CSSExpression aValue) + { + this (null, null, sFeature, null, aValue); + } + + /** + * Constructor for a range expression with the feature in front like + * (width >= 600px). + * + * @param eFeature + * The media feature. May not be null. + * @param eOperator + * The comparison operator. May not be null. + * @param aValue + * The value to compare the feature with. May not be null. + */ + public CSSMediaExpression (@NonNull final ECSSMediaExpressionFeature eFeature, + @NonNull final ECSSMediaRangeOperator eOperator, + @NonNull final CSSExpression aValue) + { + this (eFeature.getName (), eOperator, aValue); + } + + /** + * Constructor for a range expression with the feature in front like + * (width >= 600px). + * + * @param sFeature + * The media feature name. May neither be null nor empty. + * @param eOperator + * The comparison operator. May not be null. + * @param aValue + * The value to compare the feature with. May not be null. + */ + public CSSMediaExpression (@NonNull @Nonempty final String sFeature, + @NonNull final ECSSMediaRangeOperator eOperator, + @NonNull final CSSExpression aValue) + { + this (null, null, sFeature, ValueEnforcer.notNull (eOperator, "Operator"), ValueEnforcer.notNull (aValue, "Value")); + } + + /** + * Constructor for a range expression with the value in front like + * (600px <= width). + * + * @param aLeftValue + * The value to compare the feature with. May not be null. + * @param eLeftOperator + * The comparison operator. May not be null. + * @param sFeature + * The media feature name. May neither be null nor empty. + */ + public CSSMediaExpression (@NonNull final CSSExpression aLeftValue, + @NonNull final ECSSMediaRangeOperator eLeftOperator, + @NonNull @Nonempty final String sFeature) + { + this (ValueEnforcer.notNull (aLeftValue, "LeftValue"), + ValueEnforcer.notNull (eLeftOperator, "LeftOperator"), + sFeature, + null, + null); + } + + /** + * Generic constructor covering all forms. Use it directly for the two-sided range like + * (400px <= width <= 600px). + * + * @param aLeftValue + * The value in front of the feature. May be null. Must be present if and only if + * eLeftOperator is present. + * @param eLeftOperator + * The comparison operator between the left value and the feature. May be null. + * @param sFeature + * The media feature name. May neither be null nor empty. + * @param eRightOperator + * The comparison operator between the feature and the value. May be null, in + * which case a non-null value is written in the classic + * feature: value form. + * @param aValue + * The value after the feature. May be null unless eRightOperator + * is present. + */ + public CSSMediaExpression (@Nullable final CSSExpression aLeftValue, + @Nullable final ECSSMediaRangeOperator eLeftOperator, + @NonNull @Nonempty final String sFeature, + @Nullable final ECSSMediaRangeOperator eRightOperator, + @Nullable final CSSExpression aValue) { ValueEnforcer.notEmpty (sFeature, "Feature"); + ValueEnforcer.isTrue ((aLeftValue == null) == (eLeftOperator == null), + "Left value and left operator must be present or absent together"); + ValueEnforcer.isTrue (eRightOperator == null || aValue != null, "A right operator requires a value"); + m_aRangeLeftValue = aLeftValue; + m_eRangeLeftOperator = eLeftOperator; m_sFeature = sFeature; + m_eRangeRightOperator = eRightOperator; m_aValue = aValue; } + /** + * @return The value in front of the feature in the range context (600px in + * (600px <= width)). May be null. + */ + @Nullable + public final CSSExpression getRangeLeftValue () + { + return m_aRangeLeftValue; + } + + /** + * @return The comparison operator between the left value and the feature. May be null. + */ + @Nullable + public final ECSSMediaRangeOperator getRangeLeftOperator () + { + return m_eRangeLeftOperator; + } + + /** + * @return The media feature name. Neither null nor empty. + */ @NonNull @Nonempty public final String getFeature () @@ -72,20 +223,61 @@ public final String getFeature () return m_sFeature; } + /** + * @return The comparison operator between the feature and the value in the range context + * (>= in (width >= 600px)). null if the value + * is absent or uses the classic feature: value form. + */ + @Nullable + public final ECSSMediaRangeOperator getRangeRightOperator () + { + return m_eRangeRightOperator; + } + + /** + * @return The value after the feature - either after the colon of the classic form or after the + * right range operator. May be null. + */ @Nullable public final CSSExpression getValue () { return m_aValue; } + /** + * @return true if this expression uses the Media Queries Level 4 range context, + * false for the classic (feature) and + * (feature: value) forms. + */ + public final boolean isRangeContext () + { + return m_eRangeLeftOperator != null || m_eRangeRightOperator != null; + } + @NonNull @Nonempty public String getAsCSSString (@NonNull final ICSSWriterSettings aSettings, @Nonnegative final int nIndentLevel) { final StringBuilder aSB = new StringBuilder (); - aSB.append ('(').append (m_sFeature); - if (m_aValue != null) - aSB.append (CCSS.SEPARATOR_PROPERTY_VALUE).append (m_aValue.getAsCSSString (aSettings, nIndentLevel)); + aSB.append ('('); + if (m_eRangeLeftOperator != null) + { + aSB.append (m_aRangeLeftValue.getAsCSSString (aSettings, nIndentLevel)) + .append (' ') + .append (m_eRangeLeftOperator.getAsCSSString (aSettings, nIndentLevel)) + .append (' '); + } + aSB.append (m_sFeature); + if (m_eRangeRightOperator != null) + { + aSB.append (' ') + .append (m_eRangeRightOperator.getAsCSSString (aSettings, nIndentLevel)) + .append (' ') + .append (m_aValue.getAsCSSString (aSettings, nIndentLevel)); + } + else + if (m_aValue != null) + aSB.append (CCSS.SEPARATOR_PROPERTY_VALUE).append (m_aValue.getAsCSSString (aSettings, nIndentLevel)); return aSB.append (')').toString (); } @@ -108,19 +300,31 @@ public boolean equals (final Object o) if (o == null || !getClass ().equals (o.getClass ())) return false; final CSSMediaExpression rhs = (CSSMediaExpression) o; - return m_sFeature.equals (rhs.m_sFeature) && EqualsHelper.equals (m_aValue, rhs.m_aValue); + return EqualsHelper.equals (m_aRangeLeftValue, rhs.m_aRangeLeftValue) && + EqualsHelper.equals (m_eRangeLeftOperator, rhs.m_eRangeLeftOperator) && + m_sFeature.equals (rhs.m_sFeature) && + EqualsHelper.equals (m_eRangeRightOperator, rhs.m_eRangeRightOperator) && + EqualsHelper.equals (m_aValue, rhs.m_aValue); } @Override public int hashCode () { - return new HashCodeGenerator (this).append (m_sFeature).append (m_aValue).getHashCode (); + return new HashCodeGenerator (this).append (m_aRangeLeftValue) + .append (m_eRangeLeftOperator) + .append (m_sFeature) + .append (m_eRangeRightOperator) + .append (m_aValue) + .getHashCode (); } @Override public String toString () { - return new ToStringGenerator (this).append ("feature", m_sFeature) + return new ToStringGenerator (this).appendIfNotNull ("rangeLeftValue", m_aRangeLeftValue) + .appendIfNotNull ("rangeLeftOperator", m_eRangeLeftOperator) + .append ("feature", m_sFeature) + .appendIfNotNull ("rangeRightOperator", m_eRangeRightOperator) .appendIfNotNull ("value", m_aValue) .appendIfNotNull ("SourceLocation", m_aSourceLocation) .getToString (); diff --git a/ph-css/src/main/java/com/helger/css/decl/ECSSMediaRangeOperator.java b/ph-css/src/main/java/com/helger/css/decl/ECSSMediaRangeOperator.java new file mode 100644 index 00000000..14906953 --- /dev/null +++ b/ph-css/src/main/java/com/helger/css/decl/ECSSMediaRangeOperator.java @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2014-2026 Philip Helger (www.helger.com) + * philip[at]helger[dot]com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.helger.css.decl; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import com.helger.annotation.Nonempty; +import com.helger.annotation.Nonnegative; +import com.helger.base.lang.EnumHelper; +import com.helger.base.name.IHasName; +import com.helger.css.ICSSWriteable; +import com.helger.css.ICSSWriterSettings; + +/** + * Enumeration with the comparison operators of the Media Queries Level 4 range context as in + * (width >= 600px) or (400px <= width <= 600px). + * + * @see Media Queries Level 4 - + * Range context + * @since 8.1.2-unblu-4 + */ +public enum ECSSMediaRangeOperator implements ICSSWriteable, IHasName +{ + LESS ("<"), + LESS_EQUALS ("<="), + GREATER (">"), + GREATER_EQUALS (">="), + EQUALS ("="); + + private final String m_sName; + + ECSSMediaRangeOperator (@NonNull @Nonempty final String sName) + { + m_sName = sName; + } + + @NonNull + @Nonempty + public String getName () + { + return m_sName; + } + + @NonNull + @Nonempty + public String getAsCSSString (@NonNull final ICSSWriterSettings aSettings, @Nonnegative final int nIndentLevel) + { + return m_sName; + } + + @Nullable + public static ECSSMediaRangeOperator getFromNameOrNull (@Nullable final String sName) + { + return EnumHelper.getFromNameOrNull (ECSSMediaRangeOperator.class, sName); + } +} diff --git a/ph-css/src/main/java/com/helger/css/handler/CSSNodeToDomainObject.java b/ph-css/src/main/java/com/helger/css/handler/CSSNodeToDomainObject.java index 49241878..afda1a3f 100644 --- a/ph-css/src/main/java/com/helger/css/handler/CSSNodeToDomainObject.java +++ b/ph-css/src/main/java/com/helger/css/handler/CSSNodeToDomainObject.java @@ -1114,28 +1114,52 @@ private CSSMediaExpression _createMediaExpr (@NonNull final CSSNode aNode) { _expectNodeType (aNode, ECSSNodeType.MEDIAEXPR); final int nChildCount = aNode.jjtGetNumChildren (); - if (nChildCount != 1 && nChildCount != 2) - _throwUnexpectedChildrenCount (aNode, "Expected 1 or 2 children but got " + nChildCount + "!"); - - final CSSNode aFeatureNode = aNode.jjtGetChild (0); - if (!ECSSNodeType.MEDIAFEATURE.isNode (aFeatureNode)) - throw new IllegalStateException ("Expected a media feature but got " + ECSSNodeType.getNodeName (aFeatureNode)); - final String sFeature = aFeatureNode.getText (); + if (nChildCount < 1 || nChildCount > 5) + _throwUnexpectedChildrenCount (aNode, "Expected 1 to 5 children but got " + nChildCount + "!"); + + // Children in document order: [value operator] feature [operator value] resp. [feature value] + CSSExpression aLeftValue = null; + ECSSMediaRangeOperator eLeftOperator = null; + String sFeature = null; + ECSSMediaRangeOperator eRightOperator = null; + CSSExpression aValue = null; + for (final CSSNode aChildNode : aNode) + { + if (ECSSNodeType.MEDIAFEATURE.isNode (aChildNode)) + sFeature = aChildNode.getText (); + else + if (ECSSNodeType.MEDIARANGEOPERATOR.isNode (aChildNode)) + { + final String sText = aChildNode.getText (); + final ECSSMediaRangeOperator eOperator = ECSSMediaRangeOperator.getFromNameOrNull (sText); + if (eOperator == null) + m_aErrorHandler.onCSSInterpretationError ("Failed to parse media range operator '" + sText + "'"); + else + if (sFeature == null) + eLeftOperator = eOperator; + else + eRightOperator = eOperator; + } + else + if (ECSSNodeType.EXPR.isNode (aChildNode)) + { + if (sFeature == null) + aLeftValue = _createExpression (aChildNode); + else + aValue = _createExpression (aChildNode); + } + else + m_aErrorHandler.onCSSInterpretationError ("Unsupported child of " + + ECSSNodeType.getNodeName (aNode) + + ": " + + ECSSNodeType.getNodeName (aChildNode)); + } + if (sFeature == null) + throw new IllegalStateException ("Expected a media feature but got none"); if (ECSSMediaExpressionFeature.getFromNameOrNull (sFeature) == null) m_aErrorHandler.onCSSInterpretationWarning ("Media expression uses unknown feature '" + sFeature + "'"); - CSSMediaExpression ret; - if (nChildCount == 1) - { - // Feature only - ret = new CSSMediaExpression (sFeature); - } - else - { - // Feature + value - final CSSNode aValueNode = aNode.jjtGetChild (1); - ret = new CSSMediaExpression (sFeature, _createExpression (aValueNode)); - } + final CSSMediaExpression ret = new CSSMediaExpression (aLeftValue, eLeftOperator, sFeature, eRightOperator, aValue); if (m_bUseSourceLocation) ret.setSourceLocation (aNode.getSourceLocation ()); return ret; diff --git a/ph-css/src/main/java/com/helger/css/handler/ECSSNodeType.java b/ph-css/src/main/java/com/helger/css/handler/ECSSNodeType.java index 01f7cc24..f2149a52 100644 --- a/ph-css/src/main/java/com/helger/css/handler/ECSSNodeType.java +++ b/ph-css/src/main/java/com/helger/css/handler/ECSSNodeType.java @@ -96,6 +96,7 @@ public enum ECSSNodeType MEDIAMODIFIER (ParserCSS30TreeConstants.JJTMEDIAMODIFIER), MEDIAEXPR (ParserCSS30TreeConstants.JJTMEDIAEXPR), MEDIAFEATURE (ParserCSS30TreeConstants.JJTMEDIAFEATURE), + MEDIARANGEOPERATOR (ParserCSS30TreeConstants.JJTMEDIARANGEOPERATOR), // layer stuff LAYERSELECTOR (ParserCSS30TreeConstants.JJTLAYERSELECTOR), LAYERSELECTORLIST (ParserCSS30TreeConstants.JJTLAYERSELECTORLIST), diff --git a/ph-css/src/main/jjtree/ParserCSS30.jjt b/ph-css/src/main/jjtree/ParserCSS30.jjt index a19a13eb..ce95b633 100644 --- a/ph-css/src/main/jjtree/ParserCSS30.jjt +++ b/ph-css/src/main/jjtree/ParserCSS30.jjt @@ -321,6 +321,16 @@ TOKEN : ) > } +// Media Queries Level 4 range context comparison operators (see mediaRangeOperator). +// Restricted to the DEFAULT state so that the token stream of unknown rules stays unchanged. + +TOKEN : +{ + < LESS: "<" > +| < LESS_EQUALS: "<=" > +| < GREATER_EQUALS: ">=" > +} + // Single line comments are handled in all states except in unknown rules, because // there, tokens are simply chained together to a string @@ -1404,16 +1414,86 @@ void mediaFeature() : {} { jjtThis.setText (token.image); } } +// Media Queries Level 4 range context: https://www.w3.org/TR/mediaqueries-4/#mq-range-context +void mediaRangeOperator() : {} +{ + ( { jjtThis.setText ("<"); } + | { jjtThis.setText ("<="); } + | { jjtThis.setText (">"); } + | { jjtThis.setText (">="); } + | { jjtThis.setText ("="); } + ) +} + +// Value in front of the media feature ("600px <= width"). +// Deliberately excludes identifiers so that the choice against mediaFeature() is unambiguous. +void mediaRangeValueTerm() #exprTerm : +{ + String sPrefix = null; + Token t = null; +} +{ + ( ( sPrefix = unaryOperator() )? + ( t = + | t = + | t = dimension() + ) + | calc() + | function() + ) + ( )* + { + if (sPrefix != null) + jjtThis.appendText (sPrefix); + if (t != null) + jjtThis.appendText (t.image); + } +} + +// Ratio separator ("16/9") +void mediaRangeRatioOperator() #exprOperator : {} +{ + { jjtThis.setText ("/"); } +} + +void mediaRangeValue() #expr : {} +{ + mediaRangeValueTerm() + ( mediaRangeRatioOperator() + ( )* + mediaRangeValueTerm() + )? +} + +// Supported forms: +// (feature) +// (feature: value) +// (feature value) +// (value feature) +// (value feature value) void mediaExpr() : {} { ( )* - mediaFeature() - ( )* - ( + ( mediaFeature() ( )* - expr() - )? + ( + ( )* + expr() + | mediaRangeOperator() + ( )* + expr() + )? + | mediaRangeValue() + mediaRangeOperator() + ( )* + mediaFeature() + ( )* + ( mediaRangeOperator() + ( )* + expr() + )? + ) ( )* } diff --git a/ph-css/src/test/java/com/helger/css/decl/CSSMediaExpressionTest.java b/ph-css/src/test/java/com/helger/css/decl/CSSMediaExpressionTest.java new file mode 100644 index 00000000..7375617a --- /dev/null +++ b/ph-css/src/test/java/com/helger/css/decl/CSSMediaExpressionTest.java @@ -0,0 +1,286 @@ +/* + * Copyright (C) 2014-2026 Philip Helger (www.helger.com) + * philip[at]helger[dot]com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.helger.css.decl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import org.jspecify.annotations.NonNull; +import org.junit.Test; + +import com.helger.css.media.ECSSMediaExpressionFeature; +import com.helger.css.reader.CSSReader; +import com.helger.css.reader.CSSReaderSettings; +import com.helger.css.reader.errorhandler.DoNothingCSSParseErrorHandler; +import com.helger.css.writer.CSSWriterSettings; +import com.helger.unittest.support.TestHelper; + +/** + * Test class for class {@link CSSMediaExpression}, especially the Media Queries Level 4 range + * context. + */ +public final class CSSMediaExpressionTest +{ + private static final CSSWriterSettings WS = new CSSWriterSettings ().setOptimizedOutput (true); + + @NonNull + private static CSSMediaQuery _parseSingleQuery (@NonNull final String sMediaQuery) + { + // Strict mode: the media rule must be parsed, not skipped + final CascadingStyleSheet aCSS = CSSReader.readFromStringReader ("@media " + sMediaQuery + " { div { color: red } }", + new CSSReaderSettings ().setBrowserCompliantMode (false) + .setCustomErrorHandler (new DoNothingCSSParseErrorHandler ())); + assertNotNull (sMediaQuery, aCSS); + assertEquals (sMediaQuery, 1, aCSS.getMediaRuleCount ()); + final CSSMediaRule aMediaRule = aCSS.getMediaRuleAtIndex (0); + assertEquals (sMediaQuery, 1, aMediaRule.getRuleCount ()); + assertEquals (sMediaQuery, 1, aMediaRule.getMediaQueryCount ()); + return aMediaRule.getMediaQueryAtIndex (0); + } + + @NonNull + private static CSSMediaExpression _parseSingleExpression (@NonNull final String sMediaQuery) + { + final CSSMediaQuery aQuery = _parseSingleQuery (sMediaQuery); + assertEquals (sMediaQuery, 1, aQuery.getMediaExpressionCount ()); + return aQuery.getMediaExpression (0); + } + + @Test + public void testClassicForms () + { + CSSMediaExpression aExpr = _parseSingleExpression ("(color)"); + assertEquals ("color", aExpr.getFeature ()); + assertNull (aExpr.getValue ()); + assertFalse (aExpr.isRangeContext ()); + assertEquals ("(color)", aExpr.getAsCSSString (WS, 0)); + + aExpr = _parseSingleExpression ("( min-width : 600px )"); + assertEquals ("min-width", aExpr.getFeature ()); + assertEquals ("600px", aExpr.getValue ().getAsCSSString (WS, 0)); + assertFalse (aExpr.isRangeContext ()); + assertNull (aExpr.getRangeLeftValue ()); + assertNull (aExpr.getRangeLeftOperator ()); + assertNull (aExpr.getRangeRightOperator ()); + assertEquals ("(min-width:600px)", aExpr.getAsCSSString (WS, 0)); + } + + @Test + public void testRangeFeatureFirst () + { + CSSMediaExpression aExpr = _parseSingleExpression ("(width >= 600px)"); + assertTrue (aExpr.isRangeContext ()); + assertNull (aExpr.getRangeLeftValue ()); + assertNull (aExpr.getRangeLeftOperator ()); + assertEquals ("width", aExpr.getFeature ()); + assertSame (ECSSMediaRangeOperator.GREATER_EQUALS, aExpr.getRangeRightOperator ()); + assertEquals ("600px", aExpr.getValue ().getAsCSSString (WS, 0)); + assertEquals ("(width >= 600px)", aExpr.getAsCSSString (WS, 0)); + + // no whitespace at all (minified CSS) + aExpr = _parseSingleExpression ("(width>=600px)"); + assertSame (ECSSMediaRangeOperator.GREATER_EQUALS, aExpr.getRangeRightOperator ()); + assertEquals ("(width >= 600px)", aExpr.getAsCSSString (WS, 0)); + + aExpr = _parseSingleExpression ("(width<=600px)"); + assertSame (ECSSMediaRangeOperator.LESS_EQUALS, aExpr.getRangeRightOperator ()); + assertEquals ("(width <= 600px)", aExpr.getAsCSSString (WS, 0)); + + aExpr = _parseSingleExpression ("(height < 400px)"); + assertSame (ECSSMediaRangeOperator.LESS, aExpr.getRangeRightOperator ()); + assertEquals ("(height < 400px)", aExpr.getAsCSSString (WS, 0)); + + aExpr = _parseSingleExpression ("(height > 400px)"); + assertSame (ECSSMediaRangeOperator.GREATER, aExpr.getRangeRightOperator ()); + assertEquals ("(height > 400px)", aExpr.getAsCSSString (WS, 0)); + + aExpr = _parseSingleExpression ("(width = 600px)"); + assertSame (ECSSMediaRangeOperator.EQUALS, aExpr.getRangeRightOperator ()); + assertEquals ("(width = 600px)", aExpr.getAsCSSString (WS, 0)); + } + + @Test + public void testRangeValueFirst () + { + CSSMediaExpression aExpr = _parseSingleExpression ("(600px <= width)"); + assertTrue (aExpr.isRangeContext ()); + assertEquals ("600px", aExpr.getRangeLeftValue ().getAsCSSString (WS, 0)); + assertSame (ECSSMediaRangeOperator.LESS_EQUALS, aExpr.getRangeLeftOperator ()); + assertEquals ("width", aExpr.getFeature ()); + assertNull (aExpr.getRangeRightOperator ()); + assertNull (aExpr.getValue ()); + assertEquals ("(600px <= width)", aExpr.getAsCSSString (WS, 0)); + + aExpr = _parseSingleExpression ("(-1px>height)"); + assertEquals ("-1px", aExpr.getRangeLeftValue ().getAsCSSString (WS, 0)); + assertSame (ECSSMediaRangeOperator.GREATER, aExpr.getRangeLeftOperator ()); + assertEquals ("(-1px > height)", aExpr.getAsCSSString (WS, 0)); + } + + @Test + public void testRangeBothSides () + { + CSSMediaExpression aExpr = _parseSingleExpression ("(400px <= width <= 600px)"); + assertTrue (aExpr.isRangeContext ()); + assertEquals ("400px", aExpr.getRangeLeftValue ().getAsCSSString (WS, 0)); + assertSame (ECSSMediaRangeOperator.LESS_EQUALS, aExpr.getRangeLeftOperator ()); + assertEquals ("width", aExpr.getFeature ()); + assertSame (ECSSMediaRangeOperator.LESS_EQUALS, aExpr.getRangeRightOperator ()); + assertEquals ("600px", aExpr.getValue ().getAsCSSString (WS, 0)); + assertEquals ("(400px <= width <= 600px)", aExpr.getAsCSSString (WS, 0)); + + aExpr = _parseSingleExpression ("(600px>width>400px)"); + assertSame (ECSSMediaRangeOperator.GREATER, aExpr.getRangeLeftOperator ()); + assertSame (ECSSMediaRangeOperator.GREATER, aExpr.getRangeRightOperator ()); + assertEquals ("(600px > width > 400px)", aExpr.getAsCSSString (WS, 0)); + } + + @Test + public void testRangeValueTypes () + { + CSSMediaExpression aExpr = _parseSingleExpression ("(aspect-ratio >= 16/9)"); + assertEquals ("16/9", aExpr.getValue ().getAsCSSString (WS, 0)); + assertEquals ("(aspect-ratio >= 16/9)", aExpr.getAsCSSString (WS, 0)); + + aExpr = _parseSingleExpression ("(16 / 9 <= aspect-ratio)"); + assertEquals ("16/9", aExpr.getRangeLeftValue ().getAsCSSString (WS, 0)); + assertEquals ("(16/9 <= aspect-ratio)", aExpr.getAsCSSString (WS, 0)); + + aExpr = _parseSingleExpression ("(resolution >= 2dppx)"); + assertEquals ("(resolution >= 2dppx)", aExpr.getAsCSSString (WS, 0)); + + aExpr = _parseSingleExpression ("(color >= 8)"); + assertEquals ("(color >= 8)", aExpr.getAsCSSString (WS, 0)); + + aExpr = _parseSingleExpression ("(width >= calc(100px + 2em))"); + assertEquals ("(width >= calc(100px + 2em))", aExpr.getAsCSSString (WS, 0)); + + aExpr = _parseSingleExpression ("(calc(100px + 2em) <= width)"); + assertEquals ("(calc(100px + 2em) <= width)", aExpr.getAsCSSString (WS, 0)); + } + + @Test + public void testRangeInMediaQueryList () + { + final CSSMediaQuery aQuery = _parseSingleQuery ("only screen and (width>=768px) and (hover: hover)"); + assertSame (CSSMediaQuery.EModifier.ONLY, aQuery.getModifier ()); + assertEquals ("screen", aQuery.getMedium ()); + assertEquals (2, aQuery.getMediaExpressionCount ()); + assertEquals ("only screen and (width >= 768px) and (hover:hover)", aQuery.getAsCSSString (WS, 0)); + + final CascadingStyleSheet aCSS = CSSReader.readFromString ("@media (width >= 768px), print and (height < 400px) { div { color: red } }"); + assertNotNull (aCSS); + assertEquals (1, aCSS.getMediaRuleCount ()); + assertEquals (2, aCSS.getMediaRuleAtIndex (0).getMediaQueryCount ()); + assertEquals ("(width >= 768px)", aCSS.getMediaRuleAtIndex (0).getMediaQueryAtIndex (0).getAsCSSString (WS, 0)); + assertEquals ("print and (height < 400px)", aCSS.getMediaRuleAtIndex (0).getMediaQueryAtIndex (1).getAsCSSString (WS, 0)); + } + + @Test + public void testRangeInImportRule () + { + final CascadingStyleSheet aCSS = CSSReader.readFromString ("@import url(\"print.css\") (width >= 600px);"); + assertNotNull (aCSS); + assertEquals (1, aCSS.getImportRuleCount ()); + final CSSImportRule aImportRule = aCSS.getImportRuleAtIndex (0); + assertEquals (1, aImportRule.getMediaQueryCount ()); + assertEquals ("(width >= 600px)", aImportRule.getAllMediaQueries ().get (0).getAsCSSString (WS, 0)); + } + + @Test + public void testInvalidRange () + { + // Strict mode: a broken range makes the whole style sheet fail + final CSSReaderSettings aStrict = new CSSReaderSettings ().setBrowserCompliantMode (false) + .setCustomErrorHandler (new DoNothingCSSParseErrorHandler ()); + assertNull (CSSReader.readFromStringReader ("@media (width >=) { div { color: red } }", aStrict)); + assertNull (CSSReader.readFromStringReader ("@media (>= 600px) { div { color: red } }", aStrict)); + assertNull (CSSReader.readFromStringReader ("@media (600px <= <= 900px) { div { color: red } }", aStrict)); + + // Browser compliant mode: the broken media rule is skipped, the rest survives + final CSSReaderSettings aLenient = new CSSReaderSettings ().setBrowserCompliantMode (true) + .setCustomErrorHandler (new DoNothingCSSParseErrorHandler ()); + final CascadingStyleSheet aCSS = CSSReader.readFromStringReader ("@media (width >=) { div { color: red } } p { color: blue }", + aLenient); + assertNotNull (aCSS); + assertEquals (0, aCSS.getMediaRuleCount ()); + assertEquals (1, aCSS.getStyleRuleCount ()); + } + + @Test + public void testConstructorsAndEquals () + { + final CSSExpression a600 = CSSExpression.createSimple ("600px"); + final CSSExpression a900 = CSSExpression.createSimple ("900px"); + + final CSSMediaExpression aFeatureFirst = new CSSMediaExpression (ECSSMediaExpressionFeature.WIDTH, + ECSSMediaRangeOperator.GREATER_EQUALS, + a600); + assertEquals ("(width >= 600px)", aFeatureFirst.getAsCSSString (WS, 0)); + TestHelper.testDefaultImplementationWithEqualContentObject (aFeatureFirst, + new CSSMediaExpression ("width", + ECSSMediaRangeOperator.GREATER_EQUALS, + a600)); + TestHelper.testDefaultImplementationWithDifferentContentObject (aFeatureFirst, + new CSSMediaExpression ("width", a600)); + TestHelper.testDefaultImplementationWithDifferentContentObject (aFeatureFirst, + new CSSMediaExpression ("width", + ECSSMediaRangeOperator.GREATER, + a600)); + + final CSSMediaExpression aValueFirst = new CSSMediaExpression (a600, ECSSMediaRangeOperator.LESS_EQUALS, "width"); + assertEquals ("(600px <= width)", aValueFirst.getAsCSSString (WS, 0)); + TestHelper.testDefaultImplementationWithDifferentContentObject (aFeatureFirst, aValueFirst); + + final CSSMediaExpression aBoth = new CSSMediaExpression (a600, + ECSSMediaRangeOperator.LESS_EQUALS, + "width", + ECSSMediaRangeOperator.LESS_EQUALS, + a900); + assertEquals ("(600px <= width <= 900px)", aBoth.getAsCSSString (WS, 0)); + TestHelper.testDefaultImplementationWithEqualContentObject (aBoth, _parseSingleExpression ("(600px<=width<=900px)")); + TestHelper.testDefaultImplementationWithDifferentContentObject (aBoth, aValueFirst); + + // Classic form is untouched + assertEquals ("(width:600px)", new CSSMediaExpression ("width", a600).getAsCSSString (WS, 0)); + assertEquals ("(width)", new CSSMediaExpression ("width").getAsCSSString (WS, 0)); + + // Inconsistent combinations are rejected + try + { + new CSSMediaExpression (a600, null, "width", null, null); + org.junit.Assert.fail (); + } + catch (final IllegalArgumentException ex) + { + // expected + } + try + { + new CSSMediaExpression (null, null, "width", ECSSMediaRangeOperator.LESS, null); + org.junit.Assert.fail (); + } + catch (final IllegalArgumentException ex) + { + // expected + } + } +} diff --git a/ph-css/src/test/resources/testfiles/css30/good/artificial/test-mediaqueries-range.css b/ph-css/src/test/resources/testfiles/css30/good/artificial/test-mediaqueries-range.css new file mode 100644 index 00000000..217a5608 --- /dev/null +++ b/ph-css/src/test/resources/testfiles/css30/good/artificial/test-mediaqueries-range.css @@ -0,0 +1,57 @@ +/** + * Copyright (C) 2014-2026 Philip Helger (www.helger.com) + * philip[at]helger[dot]com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* Media Queries Level 4 range context - https://www.w3.org/TR/mediaqueries-4/#mq-range-context */ +@import url("print.css") (width >= 600px); + +@media (width >= 600px) { + .split { display: flex; } +} + +@media (width>=768px){.container{max-width:720px}} +@media (width<=576px){.container{max-width:100%}} + +@media (400px <= width <= 600px) { + .medium { display: block; } +} + +@media (600px > width > 400px) { + .medium-exclusive { display: block; } +} + +@media screen and (width > 768px), print and (height < 400px) { + .wide { display: block; } +} + +@media only screen and (width>=992px) and (hover: hover) { + .hover { display: block; } +} + +@media (aspect-ratio >= 16/9) and (resolution >= 2dppx) { + .hd { display: block; } +} + +@media (width >= calc(100px + 2em)) { + .calc { display: block; } +} + +@media (width = 600px) { + .exact { display: block; } +} + +@media not all and (width >= 600px) { + .narrow { display: block; } +} From c9104da4617767b800feebc0c5ff20ced2824462 Mon Sep 17 00:00:00 2001 From: Mike Wiedenbauer Date: Tue, 25 Aug 2026 20:00:46 +0200 Subject: [PATCH 10/33] add validation and merge cascade actions --- .github/workflows/promote-develop.yml | 61 ++++++++++++++++++++++++ .github/workflows/validate-master-pr.yml | 22 +++++++++ 2 files changed, 83 insertions(+) create mode 100644 .github/workflows/promote-develop.yml create mode 100644 .github/workflows/validate-master-pr.yml diff --git a/.github/workflows/promote-develop.yml b/.github/workflows/promote-develop.yml new file mode 100644 index 00000000..271fff08 --- /dev/null +++ b/.github/workflows/promote-develop.yml @@ -0,0 +1,61 @@ +name: Promote develop to master + +on: + pull_request: + types: + - closed + branches: + - develop + +permissions: + contents: write + pull-requests: write + +jobs: + promote: + if: github.event.pull_request.merged == true + + runs-on: ubuntu-latest + + steps: + - name: Create promotion PR + env: + GH_TOKEN: ${{ github.token }} + run: | + EXISTING_PR=$(gh pr list \ + --base master \ + --head develop \ + --state open \ + --json number \ + --jq '.[0].number') + + if [ -n "$EXISTING_PR" ]; then + echo "Promotion PR #$EXISTING_PR already exists." + exit 0 + fi + + gh pr create \ + --base master \ + --head develop \ + --title "Promote develop to master" \ + --body "Automatic promotion of develop to master after PR #${{ github.event.pull_request.number }} was merged." + + - name: Enable auto-merge + env: + GH_TOKEN: ${{ github.token }} + run: | + PR=$(gh pr list \ + --base master \ + --head develop \ + --state open \ + --json number \ + --jq '.[0].number') + + if [ -z "$PR" ]; then + echo "No promotion PR found." + exit 1 + fi + + gh pr merge "$PR" \ + --auto \ + --squash diff --git a/.github/workflows/validate-master-pr.yml b/.github/workflows/validate-master-pr.yml new file mode 100644 index 00000000..f07e1f22 --- /dev/null +++ b/.github/workflows/validate-master-pr.yml @@ -0,0 +1,22 @@ +name: Validate master PR + +on: + pull_request: + branches: + - master + +jobs: + validate-source: + name: Validate master source + runs-on: ubuntu-latest + + steps: + - name: Require develop as source branch + run: | + if [ "${{ github.head_ref }}" != "develop" ]; then + echo "ERROR: Pull requests into master must come from develop." + echo "Source branch: ${{ github.head_ref }}" + exit 1 + fi + + echo "Valid: develop -> master" From 4a7bc4589c9d9d5b83004777b4080efce1ed5c0e Mon Sep 17 00:00:00 2001 From: Mike Wiedenbauer Date: Tue, 25 Aug 2026 20:08:27 +0200 Subject: [PATCH 11/33] exclude pom.xml(s) on auto merge --- .github/workflows/protect-master-files.yml | 42 ++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/protect-master-files.yml diff --git a/.github/workflows/protect-master-files.yml b/.github/workflows/protect-master-files.yml new file mode 100644 index 00000000..ffd37838 --- /dev/null +++ b/.github/workflows/protect-master-files.yml @@ -0,0 +1,42 @@ +name: Protect master files + +on: + pull_request: + branches: + - master + +jobs: + check-protected-files: + name: Check protected master files + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check protected files + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + PROTECTED_FILES=( + "ph-css/pom.xml" + "ph-csscompress-maven-plugin/pom.xml" + "pom.xml" + ) + + CHANGED_FILES=$(git diff \ + --name-only \ + "$BASE_SHA" \ + "$HEAD_SHA") + + for FILE in "${PROTECTED_FILES[@]}"; do + if echo "$CHANGED_FILES" | grep -Fxq "$FILE"; then + echo "ERROR: Protected file changed: $FILE" + exit 1 + fi + done + + echo "No protected master files were changed." From 510dd75683210467dd23b76f2894cfd8c9dd1071 Mon Sep 17 00:00:00 2001 From: Mike Wiedenbauer Date: Tue, 25 Aug 2026 20:18:26 +0200 Subject: [PATCH 12/33] add auto merge and branch protection workflows --- .github/workflows/promote-develop.yml | 61 ++++++++++++++++++++++ .github/workflows/protect-master-files.yml | 42 +++++++++++++++ .github/workflows/validate-master-pr.yml | 22 ++++++++ 3 files changed, 125 insertions(+) create mode 100644 .github/workflows/promote-develop.yml create mode 100644 .github/workflows/protect-master-files.yml create mode 100644 .github/workflows/validate-master-pr.yml diff --git a/.github/workflows/promote-develop.yml b/.github/workflows/promote-develop.yml new file mode 100644 index 00000000..271fff08 --- /dev/null +++ b/.github/workflows/promote-develop.yml @@ -0,0 +1,61 @@ +name: Promote develop to master + +on: + pull_request: + types: + - closed + branches: + - develop + +permissions: + contents: write + pull-requests: write + +jobs: + promote: + if: github.event.pull_request.merged == true + + runs-on: ubuntu-latest + + steps: + - name: Create promotion PR + env: + GH_TOKEN: ${{ github.token }} + run: | + EXISTING_PR=$(gh pr list \ + --base master \ + --head develop \ + --state open \ + --json number \ + --jq '.[0].number') + + if [ -n "$EXISTING_PR" ]; then + echo "Promotion PR #$EXISTING_PR already exists." + exit 0 + fi + + gh pr create \ + --base master \ + --head develop \ + --title "Promote develop to master" \ + --body "Automatic promotion of develop to master after PR #${{ github.event.pull_request.number }} was merged." + + - name: Enable auto-merge + env: + GH_TOKEN: ${{ github.token }} + run: | + PR=$(gh pr list \ + --base master \ + --head develop \ + --state open \ + --json number \ + --jq '.[0].number') + + if [ -z "$PR" ]; then + echo "No promotion PR found." + exit 1 + fi + + gh pr merge "$PR" \ + --auto \ + --squash diff --git a/.github/workflows/protect-master-files.yml b/.github/workflows/protect-master-files.yml new file mode 100644 index 00000000..ffd37838 --- /dev/null +++ b/.github/workflows/protect-master-files.yml @@ -0,0 +1,42 @@ +name: Protect master files + +on: + pull_request: + branches: + - master + +jobs: + check-protected-files: + name: Check protected master files + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check protected files + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + PROTECTED_FILES=( + "ph-css/pom.xml" + "ph-csscompress-maven-plugin/pom.xml" + "pom.xml" + ) + + CHANGED_FILES=$(git diff \ + --name-only \ + "$BASE_SHA" \ + "$HEAD_SHA") + + for FILE in "${PROTECTED_FILES[@]}"; do + if echo "$CHANGED_FILES" | grep -Fxq "$FILE"; then + echo "ERROR: Protected file changed: $FILE" + exit 1 + fi + done + + echo "No protected master files were changed." diff --git a/.github/workflows/validate-master-pr.yml b/.github/workflows/validate-master-pr.yml new file mode 100644 index 00000000..f07e1f22 --- /dev/null +++ b/.github/workflows/validate-master-pr.yml @@ -0,0 +1,22 @@ +name: Validate master PR + +on: + pull_request: + branches: + - master + +jobs: + validate-source: + name: Validate master source + runs-on: ubuntu-latest + + steps: + - name: Require develop as source branch + run: | + if [ "${{ github.head_ref }}" != "develop" ]; then + echo "ERROR: Pull requests into master must come from develop." + echo "Source branch: ${{ github.head_ref }}" + exit 1 + fi + + echo "Valid: develop -> master" From db253ffba0f61d9e5d7f80fe8de80805d93fe5b3 Mon Sep 17 00:00:00 2001 From: Mike Wiedenbauer Date: Wed, 26 Aug 2026 08:35:50 +0200 Subject: [PATCH 13/33] auto sync with upstream master --- .github/workflows/sync-upstream.yml | 40 ++++++++++++++++++++++++ .github/workflows/validate-master-pr.yml | 17 +++++++--- 2 files changed, 52 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/sync-upstream.yml diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml new file mode 100644 index 00000000..f40e794a --- /dev/null +++ b/.github/workflows/sync-upstream.yml @@ -0,0 +1,40 @@ +name: Sync upstream master + +on: + schedule: + - cron: "0 3 * * *" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + sync: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Configure upstream + run: | + git remote add upstream https://github.com/phax/ph-css.git + git fetch upstream master + + - name: Create sync branch + run: | + git checkout -B sync/upstream-master upstream/master + git push --force origin sync/upstream-master + + - name: Create pull request + env: + GH_TOKEN: ${{ github.token }} + run: | + gh pr create \ + --base master \ + --head sync/upstream-master \ + --title "Sync upstream/master" \ + --body "Synchronize fork with upstream/master." \ + || true diff --git a/.github/workflows/validate-master-pr.yml b/.github/workflows/validate-master-pr.yml index f07e1f22..daa87c00 100644 --- a/.github/workflows/validate-master-pr.yml +++ b/.github/workflows/validate-master-pr.yml @@ -13,10 +13,17 @@ jobs: steps: - name: Require develop as source branch run: | - if [ "${{ github.head_ref }}" != "develop" ]; then - echo "ERROR: Pull requests into master must come from develop." - echo "Source branch: ${{ github.head_ref }}" - exit 1 + SOURCE="${{ github.head_ref }}" + + if [ "$SOURCE" = "develop" ]; then + echo "Valid: develop -> master" + exit 0 + fi + + if [ "$SOURCE" = "sync/upstream-master" ]; then + echo "Valid: upstream synchronization -> master" + exit 0 fi - echo "Valid: develop -> master" + echo "ERROR: Only develop or sync/upstream-master may merge into master." + exit 1 From eb2d13c58cbc54988189a26d21fc958cb17f4562 Mon Sep 17 00:00:00 2001 From: Mike Wiedenbauer Date: Wed, 26 Aug 2026 11:16:49 +0200 Subject: [PATCH 14/33] - exclude files when creating the promotion PR - protection of master files not needed anymore --- .github/workflows/promote-develop.yml | 80 ++++++++++++---------- .github/workflows/protect-master-files.yml | 42 ------------ 2 files changed, 45 insertions(+), 77 deletions(-) delete mode 100644 .github/workflows/protect-master-files.yml diff --git a/.github/workflows/promote-develop.yml b/.github/workflows/promote-develop.yml index 271fff08..b69900d5 100644 --- a/.github/workflows/promote-develop.yml +++ b/.github/workflows/promote-develop.yml @@ -2,8 +2,7 @@ name: Promote develop to master on: pull_request: - types: - - closed + types: [closed] branches: - develop @@ -18,44 +17,55 @@ jobs: runs-on: ubuntu-latest steps: - - name: Create promotion PR - env: - GH_TOKEN: ${{ github.token }} + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Configure git run: | - EXISTING_PR=$(gh pr list \ - --base master \ - --head develop \ - --state open \ - --json number \ - --jq '.[0].number') - - if [ -n "$EXISTING_PR" ]; then - echo "Promotion PR #$EXISTING_PR already exists." - exit 0 + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Fetch branches + run: | + git fetch origin master + git fetch origin develop + + - name: Create promotion branch + run: | + git checkout -B promotion/develop-to-master origin/master + + - name: Merge develop + run: | + git merge origin/develop --no-edit + + - name: Restore master-only files + run: | + git checkout origin/master -- \ + ph-css/pom.xml \ + ph-csscompress-maven-plugin/pom.xml \ + pom.xml + + git add \ + ph-css/pom.xml \ + ph-csscompress-maven-plugin/pom.xml \ + pom.xml + + if ! git diff --cached --quiet; then + git commit -m "Keep master-only files unchanged" fi - gh pr create \ - --base master \ - --head develop \ - --title "Promote develop to master" \ - --body "Automatic promotion of develop to master after PR #${{ github.event.pull_request.number }} was merged." + - name: Push promotion branch + run: | + git push --force origin promotion/develop-to-master - - name: Enable auto-merge + - name: Create pull request env: GH_TOKEN: ${{ github.token }} run: | - PR=$(gh pr list \ + gh pr create \ --base master \ - --head develop \ - --state open \ - --json number \ - --jq '.[0].number') - - if [ -z "$PR" ]; then - echo "No promotion PR found." - exit 1 - fi - - gh pr merge "$PR" \ - --auto \ - --squash + --head promotion/develop-to-master \ + --title "Promote develop to master" \ + --body "Automatic promotion of develop to master." diff --git a/.github/workflows/protect-master-files.yml b/.github/workflows/protect-master-files.yml deleted file mode 100644 index ffd37838..00000000 --- a/.github/workflows/protect-master-files.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Protect master files - -on: - pull_request: - branches: - - master - -jobs: - check-protected-files: - name: Check protected master files - runs-on: ubuntu-latest - - steps: - - name: Check out repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Check protected files - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - PROTECTED_FILES=( - "ph-css/pom.xml" - "ph-csscompress-maven-plugin/pom.xml" - "pom.xml" - ) - - CHANGED_FILES=$(git diff \ - --name-only \ - "$BASE_SHA" \ - "$HEAD_SHA") - - for FILE in "${PROTECTED_FILES[@]}"; do - if echo "$CHANGED_FILES" | grep -Fxq "$FILE"; then - echo "ERROR: Protected file changed: $FILE" - exit 1 - fi - done - - echo "No protected master files were changed." From 38e7e7ed04885962f7db4c282ecca0f91e9e503b Mon Sep 17 00:00:00 2001 From: Mike Wiedenbauer Date: Wed, 26 Aug 2026 11:33:47 +0200 Subject: [PATCH 15/33] - adopt validation --- .github/workflows/validate-master-pr.yml | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/.github/workflows/validate-master-pr.yml b/.github/workflows/validate-master-pr.yml index daa87c00..c31c0f65 100644 --- a/.github/workflows/validate-master-pr.yml +++ b/.github/workflows/validate-master-pr.yml @@ -11,19 +11,15 @@ jobs: runs-on: ubuntu-latest steps: - - name: Require develop as source branch + - name: Validate source branch run: | SOURCE="${{ github.head_ref }}" - if [ "$SOURCE" = "develop" ]; then - echo "Valid: develop -> master" - exit 0 + if [ "$SOURCE" != "promotion/develop-to-master" ] && + [ "$SOURCE" != "sync/upstream-master" ]; then + echo "ERROR: Only promotion or upstream-sync branches may merge into master." + echo "Source branch: $SOURCE" + exit 1 fi - if [ "$SOURCE" = "sync/upstream-master" ]; then - echo "Valid: upstream synchronization -> master" - exit 0 - fi - - echo "ERROR: Only develop or sync/upstream-master may merge into master." - exit 1 + echo "Valid source branch: $SOURCE" From 50f8de38d622bb516e1c619097a768c05e8d05eb Mon Sep 17 00:00:00 2001 From: Mike Wiedenbauer Date: Wed, 26 Aug 2026 12:59:51 +0200 Subject: [PATCH 16/33] - improvments --- .github/workflows/promote-develop.yml | 73 ++++++++++++++++++++---- .github/workflows/validate-master-pr.yml | 26 ++++++--- 2 files changed, 81 insertions(+), 18 deletions(-) diff --git a/.github/workflows/promote-develop.yml b/.github/workflows/promote-develop.yml index b69900d5..04c8f62a 100644 --- a/.github/workflows/promote-develop.yml +++ b/.github/workflows/promote-develop.yml @@ -2,9 +2,13 @@ name: Promote develop to master on: pull_request: - types: [closed] + types: + - opened + - reopened + - closed branches: - develop + - master permissions: contents: write @@ -12,17 +16,30 @@ permissions: jobs: promote: - if: github.event.pull_request.merged == true + if: > + ( + github.event.pull_request.base.ref == 'develop' && + github.event.pull_request.merged == true + ) + || + ( + github.event.pull_request.base.ref == 'master' && + github.event.pull_request.head.ref == 'develop' && + ( + github.event.action == 'opened' || + github.event.action == 'reopened' + ) + ) runs-on: ubuntu-latest steps: - - name: Checkout repository + - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Configure git + - name: Configure Git run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" @@ -35,9 +52,6 @@ jobs: - name: Create promotion branch run: | git checkout -B promotion/develop-to-master origin/master - - - name: Merge develop - run: | git merge origin/develop --no-edit - name: Restore master-only files @@ -60,12 +74,49 @@ jobs: run: | git push --force origin promotion/develop-to-master - - name: Create pull request + - name: Create promotion PR + env: + GH_TOKEN: ${{ github.token }} + run: | + PR=$(gh pr list \ + --base master \ + --head promotion/develop-to-master \ + --state open \ + --json number \ + --jq '.[0].number') + + if [ -z "$PR" ]; then + PR=$(gh pr create \ + --base master \ + --head promotion/develop-to-master \ + --title "Promote develop to master" \ + --body "Automatic promotion of develop to master.") + fi + + echo "Promotion PR: $PR" + + - name: Enable auto-merge env: GH_TOKEN: ${{ github.token }} run: | - gh pr create \ + PR=$(gh pr list \ --base master \ --head promotion/develop-to-master \ - --title "Promote develop to master" \ - --body "Automatic promotion of develop to master." + --state open \ + --json number \ + --jq '.[0].number') + + gh pr merge "$PR" \ + --auto \ + --squash + + - name: Close manual develop → master PR + if: > + github.event.pull_request.base.ref == 'master' && + github.event.pull_request.head.ref == 'develop' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh pr close \ + "${{ github.event.pull_request.number }}" \ + --comment "Replaced by the automatic promotion PR." diff --git a/.github/workflows/validate-master-pr.yml b/.github/workflows/validate-master-pr.yml index c31c0f65..ec1d7385 100644 --- a/.github/workflows/validate-master-pr.yml +++ b/.github/workflows/validate-master-pr.yml @@ -15,11 +15,23 @@ jobs: run: | SOURCE="${{ github.head_ref }}" - if [ "$SOURCE" != "promotion/develop-to-master" ] && - [ "$SOURCE" != "sync/upstream-master" ]; then - echo "ERROR: Only promotion or upstream-sync branches may merge into master." - echo "Source branch: $SOURCE" - exit 1 - fi + case "$SOURCE" in + promotion/develop-to-master) + echo "Valid promotion branch." + ;; - echo "Valid source branch: $SOURCE" + sync/upstream-master) + echo "Valid upstream synchronization branch." + ;; + + develop) + echo "ERROR: Direct develop -> master PR." + echo "The promotion workflow must be used." + exit 1 + ;; + + *) + echo "ERROR: Invalid source branch: $SOURCE" + exit 1 + ;; + esac From e1cc8f9fda73a24d694c0358115ade14453022bf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:17:42 +0000 Subject: [PATCH 17/33] Promote develop to master (#11) * [release] set version to "8.1.1-unblu-1" * Set version to "8.1.1-SNAPSHOT" * [release] set version to "8.1.2-unblu-1" * Set version on "8.1.2-SNAPSHOT" * [release] set version to "8.1.2-unblu-2" * Set version on "8.1.2-SNAPSHOT" * [release] set version to "8.1.2-unblu-3" * Set version on "8.1.2-SNAPSHOT" * #18758 media range operators * add validation and merge cascade actions * exclude pom.xml(s) on auto merge * Keep master-only files unchanged --------- Co-authored-by: Jeremie Bresson Co-authored-by: Mike Wiedenbauer Co-authored-by: stanislav.galfy Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../helger/css/decl/CSSMediaExpression.java | 218 ++++++++++++- .../css/decl/ECSSMediaRangeOperator.java | 71 +++++ .../css/handler/CSSNodeToDomainObject.java | 62 ++-- .../com/helger/css/handler/ECSSNodeType.java | 1 + ph-css/src/main/jjtree/ParserCSS30.jjt | 90 +++++- .../css/decl/CSSMediaExpressionTest.java | 286 ++++++++++++++++++ .../artificial/test-mediaqueries-range.css | 57 ++++ 7 files changed, 754 insertions(+), 31 deletions(-) create mode 100644 ph-css/src/main/java/com/helger/css/decl/ECSSMediaRangeOperator.java create mode 100644 ph-css/src/test/java/com/helger/css/decl/CSSMediaExpressionTest.java create mode 100644 ph-css/src/test/resources/testfiles/css30/good/artificial/test-mediaqueries-range.css diff --git a/ph-css/src/main/java/com/helger/css/decl/CSSMediaExpression.java b/ph-css/src/main/java/com/helger/css/decl/CSSMediaExpression.java index 1855999c..555b2bbb 100644 --- a/ph-css/src/main/java/com/helger/css/decl/CSSMediaExpression.java +++ b/ph-css/src/main/java/com/helger/css/decl/CSSMediaExpression.java @@ -34,37 +34,188 @@ import com.helger.css.media.ECSSMediaExpressionFeature; /** - * Represents a single media expression + * Represents a single media expression. Besides the classic (feature) and + * (feature: value) forms, the Media Queries Level 4 range context is supported: + * (feature >= value), (value <= feature) and + * (value <= feature <= value). + * + * @see Media Queries Level 4 - + * Range context */ @NotThreadSafe public class CSSMediaExpression implements ICSSWriteable, ICSSSourceLocationAware { + private final CSSExpression m_aRangeLeftValue; + private final ECSSMediaRangeOperator m_eRangeLeftOperator; private final String m_sFeature; + private final ECSSMediaRangeOperator m_eRangeRightOperator; private final CSSExpression m_aValue; private CSSSourceLocation m_aSourceLocation; + /** + * Constructor for a feature only expression like (color). + * + * @param eFeature + * The media feature. May not be null. + */ public CSSMediaExpression (@NonNull final ECSSMediaExpressionFeature eFeature) { this (eFeature.getName ()); } + /** + * Constructor for a feature only expression like (color). + * + * @param sFeature + * The media feature name. May neither be null nor empty. + */ public CSSMediaExpression (@NonNull @Nonempty final String sFeature) { this (sFeature, null); } + /** + * Constructor for a feature with value like (min-width: 600px). + * + * @param eFeature + * The media feature. May not be null. + * @param aValue + * The value of the feature. May be null. + */ public CSSMediaExpression (@NonNull final ECSSMediaExpressionFeature eFeature, @Nullable final CSSExpression aValue) { this (eFeature.getName (), aValue); } + /** + * Constructor for a feature with value like (min-width: 600px). + * + * @param sFeature + * The media feature name. May neither be null nor empty. + * @param aValue + * The value of the feature. May be null. + */ public CSSMediaExpression (@NonNull @Nonempty final String sFeature, @Nullable final CSSExpression aValue) + { + this (null, null, sFeature, null, aValue); + } + + /** + * Constructor for a range expression with the feature in front like + * (width >= 600px). + * + * @param eFeature + * The media feature. May not be null. + * @param eOperator + * The comparison operator. May not be null. + * @param aValue + * The value to compare the feature with. May not be null. + */ + public CSSMediaExpression (@NonNull final ECSSMediaExpressionFeature eFeature, + @NonNull final ECSSMediaRangeOperator eOperator, + @NonNull final CSSExpression aValue) + { + this (eFeature.getName (), eOperator, aValue); + } + + /** + * Constructor for a range expression with the feature in front like + * (width >= 600px). + * + * @param sFeature + * The media feature name. May neither be null nor empty. + * @param eOperator + * The comparison operator. May not be null. + * @param aValue + * The value to compare the feature with. May not be null. + */ + public CSSMediaExpression (@NonNull @Nonempty final String sFeature, + @NonNull final ECSSMediaRangeOperator eOperator, + @NonNull final CSSExpression aValue) + { + this (null, null, sFeature, ValueEnforcer.notNull (eOperator, "Operator"), ValueEnforcer.notNull (aValue, "Value")); + } + + /** + * Constructor for a range expression with the value in front like + * (600px <= width). + * + * @param aLeftValue + * The value to compare the feature with. May not be null. + * @param eLeftOperator + * The comparison operator. May not be null. + * @param sFeature + * The media feature name. May neither be null nor empty. + */ + public CSSMediaExpression (@NonNull final CSSExpression aLeftValue, + @NonNull final ECSSMediaRangeOperator eLeftOperator, + @NonNull @Nonempty final String sFeature) + { + this (ValueEnforcer.notNull (aLeftValue, "LeftValue"), + ValueEnforcer.notNull (eLeftOperator, "LeftOperator"), + sFeature, + null, + null); + } + + /** + * Generic constructor covering all forms. Use it directly for the two-sided range like + * (400px <= width <= 600px). + * + * @param aLeftValue + * The value in front of the feature. May be null. Must be present if and only if + * eLeftOperator is present. + * @param eLeftOperator + * The comparison operator between the left value and the feature. May be null. + * @param sFeature + * The media feature name. May neither be null nor empty. + * @param eRightOperator + * The comparison operator between the feature and the value. May be null, in + * which case a non-null value is written in the classic + * feature: value form. + * @param aValue + * The value after the feature. May be null unless eRightOperator + * is present. + */ + public CSSMediaExpression (@Nullable final CSSExpression aLeftValue, + @Nullable final ECSSMediaRangeOperator eLeftOperator, + @NonNull @Nonempty final String sFeature, + @Nullable final ECSSMediaRangeOperator eRightOperator, + @Nullable final CSSExpression aValue) { ValueEnforcer.notEmpty (sFeature, "Feature"); + ValueEnforcer.isTrue ((aLeftValue == null) == (eLeftOperator == null), + "Left value and left operator must be present or absent together"); + ValueEnforcer.isTrue (eRightOperator == null || aValue != null, "A right operator requires a value"); + m_aRangeLeftValue = aLeftValue; + m_eRangeLeftOperator = eLeftOperator; m_sFeature = sFeature; + m_eRangeRightOperator = eRightOperator; m_aValue = aValue; } + /** + * @return The value in front of the feature in the range context (600px in + * (600px <= width)). May be null. + */ + @Nullable + public final CSSExpression getRangeLeftValue () + { + return m_aRangeLeftValue; + } + + /** + * @return The comparison operator between the left value and the feature. May be null. + */ + @Nullable + public final ECSSMediaRangeOperator getRangeLeftOperator () + { + return m_eRangeLeftOperator; + } + + /** + * @return The media feature name. Neither null nor empty. + */ @NonNull @Nonempty public final String getFeature () @@ -72,20 +223,61 @@ public final String getFeature () return m_sFeature; } + /** + * @return The comparison operator between the feature and the value in the range context + * (>= in (width >= 600px)). null if the value + * is absent or uses the classic feature: value form. + */ + @Nullable + public final ECSSMediaRangeOperator getRangeRightOperator () + { + return m_eRangeRightOperator; + } + + /** + * @return The value after the feature - either after the colon of the classic form or after the + * right range operator. May be null. + */ @Nullable public final CSSExpression getValue () { return m_aValue; } + /** + * @return true if this expression uses the Media Queries Level 4 range context, + * false for the classic (feature) and + * (feature: value) forms. + */ + public final boolean isRangeContext () + { + return m_eRangeLeftOperator != null || m_eRangeRightOperator != null; + } + @NonNull @Nonempty public String getAsCSSString (@NonNull final ICSSWriterSettings aSettings, @Nonnegative final int nIndentLevel) { final StringBuilder aSB = new StringBuilder (); - aSB.append ('(').append (m_sFeature); - if (m_aValue != null) - aSB.append (CCSS.SEPARATOR_PROPERTY_VALUE).append (m_aValue.getAsCSSString (aSettings, nIndentLevel)); + aSB.append ('('); + if (m_eRangeLeftOperator != null) + { + aSB.append (m_aRangeLeftValue.getAsCSSString (aSettings, nIndentLevel)) + .append (' ') + .append (m_eRangeLeftOperator.getAsCSSString (aSettings, nIndentLevel)) + .append (' '); + } + aSB.append (m_sFeature); + if (m_eRangeRightOperator != null) + { + aSB.append (' ') + .append (m_eRangeRightOperator.getAsCSSString (aSettings, nIndentLevel)) + .append (' ') + .append (m_aValue.getAsCSSString (aSettings, nIndentLevel)); + } + else + if (m_aValue != null) + aSB.append (CCSS.SEPARATOR_PROPERTY_VALUE).append (m_aValue.getAsCSSString (aSettings, nIndentLevel)); return aSB.append (')').toString (); } @@ -108,19 +300,31 @@ public boolean equals (final Object o) if (o == null || !getClass ().equals (o.getClass ())) return false; final CSSMediaExpression rhs = (CSSMediaExpression) o; - return m_sFeature.equals (rhs.m_sFeature) && EqualsHelper.equals (m_aValue, rhs.m_aValue); + return EqualsHelper.equals (m_aRangeLeftValue, rhs.m_aRangeLeftValue) && + EqualsHelper.equals (m_eRangeLeftOperator, rhs.m_eRangeLeftOperator) && + m_sFeature.equals (rhs.m_sFeature) && + EqualsHelper.equals (m_eRangeRightOperator, rhs.m_eRangeRightOperator) && + EqualsHelper.equals (m_aValue, rhs.m_aValue); } @Override public int hashCode () { - return new HashCodeGenerator (this).append (m_sFeature).append (m_aValue).getHashCode (); + return new HashCodeGenerator (this).append (m_aRangeLeftValue) + .append (m_eRangeLeftOperator) + .append (m_sFeature) + .append (m_eRangeRightOperator) + .append (m_aValue) + .getHashCode (); } @Override public String toString () { - return new ToStringGenerator (this).append ("feature", m_sFeature) + return new ToStringGenerator (this).appendIfNotNull ("rangeLeftValue", m_aRangeLeftValue) + .appendIfNotNull ("rangeLeftOperator", m_eRangeLeftOperator) + .append ("feature", m_sFeature) + .appendIfNotNull ("rangeRightOperator", m_eRangeRightOperator) .appendIfNotNull ("value", m_aValue) .appendIfNotNull ("SourceLocation", m_aSourceLocation) .getToString (); diff --git a/ph-css/src/main/java/com/helger/css/decl/ECSSMediaRangeOperator.java b/ph-css/src/main/java/com/helger/css/decl/ECSSMediaRangeOperator.java new file mode 100644 index 00000000..14906953 --- /dev/null +++ b/ph-css/src/main/java/com/helger/css/decl/ECSSMediaRangeOperator.java @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2014-2026 Philip Helger (www.helger.com) + * philip[at]helger[dot]com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.helger.css.decl; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import com.helger.annotation.Nonempty; +import com.helger.annotation.Nonnegative; +import com.helger.base.lang.EnumHelper; +import com.helger.base.name.IHasName; +import com.helger.css.ICSSWriteable; +import com.helger.css.ICSSWriterSettings; + +/** + * Enumeration with the comparison operators of the Media Queries Level 4 range context as in + * (width >= 600px) or (400px <= width <= 600px). + * + * @see Media Queries Level 4 - + * Range context + * @since 8.1.2-unblu-4 + */ +public enum ECSSMediaRangeOperator implements ICSSWriteable, IHasName +{ + LESS ("<"), + LESS_EQUALS ("<="), + GREATER (">"), + GREATER_EQUALS (">="), + EQUALS ("="); + + private final String m_sName; + + ECSSMediaRangeOperator (@NonNull @Nonempty final String sName) + { + m_sName = sName; + } + + @NonNull + @Nonempty + public String getName () + { + return m_sName; + } + + @NonNull + @Nonempty + public String getAsCSSString (@NonNull final ICSSWriterSettings aSettings, @Nonnegative final int nIndentLevel) + { + return m_sName; + } + + @Nullable + public static ECSSMediaRangeOperator getFromNameOrNull (@Nullable final String sName) + { + return EnumHelper.getFromNameOrNull (ECSSMediaRangeOperator.class, sName); + } +} diff --git a/ph-css/src/main/java/com/helger/css/handler/CSSNodeToDomainObject.java b/ph-css/src/main/java/com/helger/css/handler/CSSNodeToDomainObject.java index eec5223a..fa21c9eb 100644 --- a/ph-css/src/main/java/com/helger/css/handler/CSSNodeToDomainObject.java +++ b/ph-css/src/main/java/com/helger/css/handler/CSSNodeToDomainObject.java @@ -1120,28 +1120,52 @@ private CSSMediaExpression _createMediaExpr (@NonNull final CSSNode aNode) { _expectNodeType (aNode, ECSSNodeType.MEDIAEXPR); final int nChildCount = aNode.jjtGetNumChildren (); - if (nChildCount != 1 && nChildCount != 2) - _throwUnexpectedChildrenCount (aNode, "Expected 1 or 2 children but got " + nChildCount + "!"); - - final CSSNode aFeatureNode = aNode.jjtGetChild (0); - if (!ECSSNodeType.MEDIAFEATURE.isNode (aFeatureNode)) - throw new IllegalStateException ("Expected a media feature but got " + ECSSNodeType.getNodeName (aFeatureNode)); - final String sFeature = aFeatureNode.getText (); + if (nChildCount < 1 || nChildCount > 5) + _throwUnexpectedChildrenCount (aNode, "Expected 1 to 5 children but got " + nChildCount + "!"); + + // Children in document order: [value operator] feature [operator value] resp. [feature value] + CSSExpression aLeftValue = null; + ECSSMediaRangeOperator eLeftOperator = null; + String sFeature = null; + ECSSMediaRangeOperator eRightOperator = null; + CSSExpression aValue = null; + for (final CSSNode aChildNode : aNode) + { + if (ECSSNodeType.MEDIAFEATURE.isNode (aChildNode)) + sFeature = aChildNode.getText (); + else + if (ECSSNodeType.MEDIARANGEOPERATOR.isNode (aChildNode)) + { + final String sText = aChildNode.getText (); + final ECSSMediaRangeOperator eOperator = ECSSMediaRangeOperator.getFromNameOrNull (sText); + if (eOperator == null) + m_aErrorHandler.onCSSInterpretationError ("Failed to parse media range operator '" + sText + "'"); + else + if (sFeature == null) + eLeftOperator = eOperator; + else + eRightOperator = eOperator; + } + else + if (ECSSNodeType.EXPR.isNode (aChildNode)) + { + if (sFeature == null) + aLeftValue = _createExpression (aChildNode); + else + aValue = _createExpression (aChildNode); + } + else + m_aErrorHandler.onCSSInterpretationError ("Unsupported child of " + + ECSSNodeType.getNodeName (aNode) + + ": " + + ECSSNodeType.getNodeName (aChildNode)); + } + if (sFeature == null) + throw new IllegalStateException ("Expected a media feature but got none"); if (ECSSMediaExpressionFeature.getFromNameOrNull (sFeature) == null) m_aErrorHandler.onCSSInterpretationWarning ("Media expression uses unknown feature '" + sFeature + "'"); - CSSMediaExpression ret; - if (nChildCount == 1) - { - // Feature only - ret = new CSSMediaExpression (sFeature); - } - else - { - // Feature + value - final CSSNode aValueNode = aNode.jjtGetChild (1); - ret = new CSSMediaExpression (sFeature, _createExpression (aValueNode)); - } + final CSSMediaExpression ret = new CSSMediaExpression (aLeftValue, eLeftOperator, sFeature, eRightOperator, aValue); if (m_bUseSourceLocation) ret.setSourceLocation (aNode.getSourceLocation ()); return ret; diff --git a/ph-css/src/main/java/com/helger/css/handler/ECSSNodeType.java b/ph-css/src/main/java/com/helger/css/handler/ECSSNodeType.java index f762ff69..8b8cc047 100644 --- a/ph-css/src/main/java/com/helger/css/handler/ECSSNodeType.java +++ b/ph-css/src/main/java/com/helger/css/handler/ECSSNodeType.java @@ -96,6 +96,7 @@ public enum ECSSNodeType MEDIAMODIFIER (ParserCSS30TreeConstants.JJTMEDIAMODIFIER), MEDIAEXPR (ParserCSS30TreeConstants.JJTMEDIAEXPR), MEDIAFEATURE (ParserCSS30TreeConstants.JJTMEDIAFEATURE), + MEDIARANGEOPERATOR (ParserCSS30TreeConstants.JJTMEDIARANGEOPERATOR), // layer stuff LAYERSELECTOR (ParserCSS30TreeConstants.JJTLAYERSELECTOR), LAYERSELECTORLIST (ParserCSS30TreeConstants.JJTLAYERSELECTORLIST), diff --git a/ph-css/src/main/jjtree/ParserCSS30.jjt b/ph-css/src/main/jjtree/ParserCSS30.jjt index 6463f89d..a793156a 100644 --- a/ph-css/src/main/jjtree/ParserCSS30.jjt +++ b/ph-css/src/main/jjtree/ParserCSS30.jjt @@ -321,6 +321,16 @@ TOKEN : ) > } +// Media Queries Level 4 range context comparison operators (see mediaRangeOperator). +// Restricted to the DEFAULT state so that the token stream of unknown rules stays unchanged. + +TOKEN : +{ + < LESS: "<" > +| < LESS_EQUALS: "<=" > +| < GREATER_EQUALS: ">=" > +} + // Single line comments are handled in all states except in unknown rules, because // there, tokens are simply chained together to a string @@ -1405,16 +1415,86 @@ void mediaFeature() : {} { jjtThis.setText (token.image); } } +// Media Queries Level 4 range context: https://www.w3.org/TR/mediaqueries-4/#mq-range-context +void mediaRangeOperator() : {} +{ + ( { jjtThis.setText ("<"); } + | { jjtThis.setText ("<="); } + | { jjtThis.setText (">"); } + | { jjtThis.setText (">="); } + | { jjtThis.setText ("="); } + ) +} + +// Value in front of the media feature ("600px <= width"). +// Deliberately excludes identifiers so that the choice against mediaFeature() is unambiguous. +void mediaRangeValueTerm() #exprTerm : +{ + String sPrefix = null; + Token t = null; +} +{ + ( ( sPrefix = unaryOperator() )? + ( t = + | t = + | t = dimension() + ) + | calc() + | function() + ) + ( )* + { + if (sPrefix != null) + jjtThis.appendText (sPrefix); + if (t != null) + jjtThis.appendText (t.image); + } +} + +// Ratio separator ("16/9") +void mediaRangeRatioOperator() #exprOperator : {} +{ + { jjtThis.setText ("/"); } +} + +void mediaRangeValue() #expr : {} +{ + mediaRangeValueTerm() + ( mediaRangeRatioOperator() + ( )* + mediaRangeValueTerm() + )? +} + +// Supported forms: +// (feature) +// (feature: value) +// (feature value) +// (value feature) +// (value feature value) void mediaExpr() : {} { ( )* - mediaFeature() - ( )* - ( + ( mediaFeature() ( )* - expr() - )? + ( + ( )* + expr() + | mediaRangeOperator() + ( )* + expr() + )? + | mediaRangeValue() + mediaRangeOperator() + ( )* + mediaFeature() + ( )* + ( mediaRangeOperator() + ( )* + expr() + )? + ) ( )* } diff --git a/ph-css/src/test/java/com/helger/css/decl/CSSMediaExpressionTest.java b/ph-css/src/test/java/com/helger/css/decl/CSSMediaExpressionTest.java new file mode 100644 index 00000000..7375617a --- /dev/null +++ b/ph-css/src/test/java/com/helger/css/decl/CSSMediaExpressionTest.java @@ -0,0 +1,286 @@ +/* + * Copyright (C) 2014-2026 Philip Helger (www.helger.com) + * philip[at]helger[dot]com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.helger.css.decl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import org.jspecify.annotations.NonNull; +import org.junit.Test; + +import com.helger.css.media.ECSSMediaExpressionFeature; +import com.helger.css.reader.CSSReader; +import com.helger.css.reader.CSSReaderSettings; +import com.helger.css.reader.errorhandler.DoNothingCSSParseErrorHandler; +import com.helger.css.writer.CSSWriterSettings; +import com.helger.unittest.support.TestHelper; + +/** + * Test class for class {@link CSSMediaExpression}, especially the Media Queries Level 4 range + * context. + */ +public final class CSSMediaExpressionTest +{ + private static final CSSWriterSettings WS = new CSSWriterSettings ().setOptimizedOutput (true); + + @NonNull + private static CSSMediaQuery _parseSingleQuery (@NonNull final String sMediaQuery) + { + // Strict mode: the media rule must be parsed, not skipped + final CascadingStyleSheet aCSS = CSSReader.readFromStringReader ("@media " + sMediaQuery + " { div { color: red } }", + new CSSReaderSettings ().setBrowserCompliantMode (false) + .setCustomErrorHandler (new DoNothingCSSParseErrorHandler ())); + assertNotNull (sMediaQuery, aCSS); + assertEquals (sMediaQuery, 1, aCSS.getMediaRuleCount ()); + final CSSMediaRule aMediaRule = aCSS.getMediaRuleAtIndex (0); + assertEquals (sMediaQuery, 1, aMediaRule.getRuleCount ()); + assertEquals (sMediaQuery, 1, aMediaRule.getMediaQueryCount ()); + return aMediaRule.getMediaQueryAtIndex (0); + } + + @NonNull + private static CSSMediaExpression _parseSingleExpression (@NonNull final String sMediaQuery) + { + final CSSMediaQuery aQuery = _parseSingleQuery (sMediaQuery); + assertEquals (sMediaQuery, 1, aQuery.getMediaExpressionCount ()); + return aQuery.getMediaExpression (0); + } + + @Test + public void testClassicForms () + { + CSSMediaExpression aExpr = _parseSingleExpression ("(color)"); + assertEquals ("color", aExpr.getFeature ()); + assertNull (aExpr.getValue ()); + assertFalse (aExpr.isRangeContext ()); + assertEquals ("(color)", aExpr.getAsCSSString (WS, 0)); + + aExpr = _parseSingleExpression ("( min-width : 600px )"); + assertEquals ("min-width", aExpr.getFeature ()); + assertEquals ("600px", aExpr.getValue ().getAsCSSString (WS, 0)); + assertFalse (aExpr.isRangeContext ()); + assertNull (aExpr.getRangeLeftValue ()); + assertNull (aExpr.getRangeLeftOperator ()); + assertNull (aExpr.getRangeRightOperator ()); + assertEquals ("(min-width:600px)", aExpr.getAsCSSString (WS, 0)); + } + + @Test + public void testRangeFeatureFirst () + { + CSSMediaExpression aExpr = _parseSingleExpression ("(width >= 600px)"); + assertTrue (aExpr.isRangeContext ()); + assertNull (aExpr.getRangeLeftValue ()); + assertNull (aExpr.getRangeLeftOperator ()); + assertEquals ("width", aExpr.getFeature ()); + assertSame (ECSSMediaRangeOperator.GREATER_EQUALS, aExpr.getRangeRightOperator ()); + assertEquals ("600px", aExpr.getValue ().getAsCSSString (WS, 0)); + assertEquals ("(width >= 600px)", aExpr.getAsCSSString (WS, 0)); + + // no whitespace at all (minified CSS) + aExpr = _parseSingleExpression ("(width>=600px)"); + assertSame (ECSSMediaRangeOperator.GREATER_EQUALS, aExpr.getRangeRightOperator ()); + assertEquals ("(width >= 600px)", aExpr.getAsCSSString (WS, 0)); + + aExpr = _parseSingleExpression ("(width<=600px)"); + assertSame (ECSSMediaRangeOperator.LESS_EQUALS, aExpr.getRangeRightOperator ()); + assertEquals ("(width <= 600px)", aExpr.getAsCSSString (WS, 0)); + + aExpr = _parseSingleExpression ("(height < 400px)"); + assertSame (ECSSMediaRangeOperator.LESS, aExpr.getRangeRightOperator ()); + assertEquals ("(height < 400px)", aExpr.getAsCSSString (WS, 0)); + + aExpr = _parseSingleExpression ("(height > 400px)"); + assertSame (ECSSMediaRangeOperator.GREATER, aExpr.getRangeRightOperator ()); + assertEquals ("(height > 400px)", aExpr.getAsCSSString (WS, 0)); + + aExpr = _parseSingleExpression ("(width = 600px)"); + assertSame (ECSSMediaRangeOperator.EQUALS, aExpr.getRangeRightOperator ()); + assertEquals ("(width = 600px)", aExpr.getAsCSSString (WS, 0)); + } + + @Test + public void testRangeValueFirst () + { + CSSMediaExpression aExpr = _parseSingleExpression ("(600px <= width)"); + assertTrue (aExpr.isRangeContext ()); + assertEquals ("600px", aExpr.getRangeLeftValue ().getAsCSSString (WS, 0)); + assertSame (ECSSMediaRangeOperator.LESS_EQUALS, aExpr.getRangeLeftOperator ()); + assertEquals ("width", aExpr.getFeature ()); + assertNull (aExpr.getRangeRightOperator ()); + assertNull (aExpr.getValue ()); + assertEquals ("(600px <= width)", aExpr.getAsCSSString (WS, 0)); + + aExpr = _parseSingleExpression ("(-1px>height)"); + assertEquals ("-1px", aExpr.getRangeLeftValue ().getAsCSSString (WS, 0)); + assertSame (ECSSMediaRangeOperator.GREATER, aExpr.getRangeLeftOperator ()); + assertEquals ("(-1px > height)", aExpr.getAsCSSString (WS, 0)); + } + + @Test + public void testRangeBothSides () + { + CSSMediaExpression aExpr = _parseSingleExpression ("(400px <= width <= 600px)"); + assertTrue (aExpr.isRangeContext ()); + assertEquals ("400px", aExpr.getRangeLeftValue ().getAsCSSString (WS, 0)); + assertSame (ECSSMediaRangeOperator.LESS_EQUALS, aExpr.getRangeLeftOperator ()); + assertEquals ("width", aExpr.getFeature ()); + assertSame (ECSSMediaRangeOperator.LESS_EQUALS, aExpr.getRangeRightOperator ()); + assertEquals ("600px", aExpr.getValue ().getAsCSSString (WS, 0)); + assertEquals ("(400px <= width <= 600px)", aExpr.getAsCSSString (WS, 0)); + + aExpr = _parseSingleExpression ("(600px>width>400px)"); + assertSame (ECSSMediaRangeOperator.GREATER, aExpr.getRangeLeftOperator ()); + assertSame (ECSSMediaRangeOperator.GREATER, aExpr.getRangeRightOperator ()); + assertEquals ("(600px > width > 400px)", aExpr.getAsCSSString (WS, 0)); + } + + @Test + public void testRangeValueTypes () + { + CSSMediaExpression aExpr = _parseSingleExpression ("(aspect-ratio >= 16/9)"); + assertEquals ("16/9", aExpr.getValue ().getAsCSSString (WS, 0)); + assertEquals ("(aspect-ratio >= 16/9)", aExpr.getAsCSSString (WS, 0)); + + aExpr = _parseSingleExpression ("(16 / 9 <= aspect-ratio)"); + assertEquals ("16/9", aExpr.getRangeLeftValue ().getAsCSSString (WS, 0)); + assertEquals ("(16/9 <= aspect-ratio)", aExpr.getAsCSSString (WS, 0)); + + aExpr = _parseSingleExpression ("(resolution >= 2dppx)"); + assertEquals ("(resolution >= 2dppx)", aExpr.getAsCSSString (WS, 0)); + + aExpr = _parseSingleExpression ("(color >= 8)"); + assertEquals ("(color >= 8)", aExpr.getAsCSSString (WS, 0)); + + aExpr = _parseSingleExpression ("(width >= calc(100px + 2em))"); + assertEquals ("(width >= calc(100px + 2em))", aExpr.getAsCSSString (WS, 0)); + + aExpr = _parseSingleExpression ("(calc(100px + 2em) <= width)"); + assertEquals ("(calc(100px + 2em) <= width)", aExpr.getAsCSSString (WS, 0)); + } + + @Test + public void testRangeInMediaQueryList () + { + final CSSMediaQuery aQuery = _parseSingleQuery ("only screen and (width>=768px) and (hover: hover)"); + assertSame (CSSMediaQuery.EModifier.ONLY, aQuery.getModifier ()); + assertEquals ("screen", aQuery.getMedium ()); + assertEquals (2, aQuery.getMediaExpressionCount ()); + assertEquals ("only screen and (width >= 768px) and (hover:hover)", aQuery.getAsCSSString (WS, 0)); + + final CascadingStyleSheet aCSS = CSSReader.readFromString ("@media (width >= 768px), print and (height < 400px) { div { color: red } }"); + assertNotNull (aCSS); + assertEquals (1, aCSS.getMediaRuleCount ()); + assertEquals (2, aCSS.getMediaRuleAtIndex (0).getMediaQueryCount ()); + assertEquals ("(width >= 768px)", aCSS.getMediaRuleAtIndex (0).getMediaQueryAtIndex (0).getAsCSSString (WS, 0)); + assertEquals ("print and (height < 400px)", aCSS.getMediaRuleAtIndex (0).getMediaQueryAtIndex (1).getAsCSSString (WS, 0)); + } + + @Test + public void testRangeInImportRule () + { + final CascadingStyleSheet aCSS = CSSReader.readFromString ("@import url(\"print.css\") (width >= 600px);"); + assertNotNull (aCSS); + assertEquals (1, aCSS.getImportRuleCount ()); + final CSSImportRule aImportRule = aCSS.getImportRuleAtIndex (0); + assertEquals (1, aImportRule.getMediaQueryCount ()); + assertEquals ("(width >= 600px)", aImportRule.getAllMediaQueries ().get (0).getAsCSSString (WS, 0)); + } + + @Test + public void testInvalidRange () + { + // Strict mode: a broken range makes the whole style sheet fail + final CSSReaderSettings aStrict = new CSSReaderSettings ().setBrowserCompliantMode (false) + .setCustomErrorHandler (new DoNothingCSSParseErrorHandler ()); + assertNull (CSSReader.readFromStringReader ("@media (width >=) { div { color: red } }", aStrict)); + assertNull (CSSReader.readFromStringReader ("@media (>= 600px) { div { color: red } }", aStrict)); + assertNull (CSSReader.readFromStringReader ("@media (600px <= <= 900px) { div { color: red } }", aStrict)); + + // Browser compliant mode: the broken media rule is skipped, the rest survives + final CSSReaderSettings aLenient = new CSSReaderSettings ().setBrowserCompliantMode (true) + .setCustomErrorHandler (new DoNothingCSSParseErrorHandler ()); + final CascadingStyleSheet aCSS = CSSReader.readFromStringReader ("@media (width >=) { div { color: red } } p { color: blue }", + aLenient); + assertNotNull (aCSS); + assertEquals (0, aCSS.getMediaRuleCount ()); + assertEquals (1, aCSS.getStyleRuleCount ()); + } + + @Test + public void testConstructorsAndEquals () + { + final CSSExpression a600 = CSSExpression.createSimple ("600px"); + final CSSExpression a900 = CSSExpression.createSimple ("900px"); + + final CSSMediaExpression aFeatureFirst = new CSSMediaExpression (ECSSMediaExpressionFeature.WIDTH, + ECSSMediaRangeOperator.GREATER_EQUALS, + a600); + assertEquals ("(width >= 600px)", aFeatureFirst.getAsCSSString (WS, 0)); + TestHelper.testDefaultImplementationWithEqualContentObject (aFeatureFirst, + new CSSMediaExpression ("width", + ECSSMediaRangeOperator.GREATER_EQUALS, + a600)); + TestHelper.testDefaultImplementationWithDifferentContentObject (aFeatureFirst, + new CSSMediaExpression ("width", a600)); + TestHelper.testDefaultImplementationWithDifferentContentObject (aFeatureFirst, + new CSSMediaExpression ("width", + ECSSMediaRangeOperator.GREATER, + a600)); + + final CSSMediaExpression aValueFirst = new CSSMediaExpression (a600, ECSSMediaRangeOperator.LESS_EQUALS, "width"); + assertEquals ("(600px <= width)", aValueFirst.getAsCSSString (WS, 0)); + TestHelper.testDefaultImplementationWithDifferentContentObject (aFeatureFirst, aValueFirst); + + final CSSMediaExpression aBoth = new CSSMediaExpression (a600, + ECSSMediaRangeOperator.LESS_EQUALS, + "width", + ECSSMediaRangeOperator.LESS_EQUALS, + a900); + assertEquals ("(600px <= width <= 900px)", aBoth.getAsCSSString (WS, 0)); + TestHelper.testDefaultImplementationWithEqualContentObject (aBoth, _parseSingleExpression ("(600px<=width<=900px)")); + TestHelper.testDefaultImplementationWithDifferentContentObject (aBoth, aValueFirst); + + // Classic form is untouched + assertEquals ("(width:600px)", new CSSMediaExpression ("width", a600).getAsCSSString (WS, 0)); + assertEquals ("(width)", new CSSMediaExpression ("width").getAsCSSString (WS, 0)); + + // Inconsistent combinations are rejected + try + { + new CSSMediaExpression (a600, null, "width", null, null); + org.junit.Assert.fail (); + } + catch (final IllegalArgumentException ex) + { + // expected + } + try + { + new CSSMediaExpression (null, null, "width", ECSSMediaRangeOperator.LESS, null); + org.junit.Assert.fail (); + } + catch (final IllegalArgumentException ex) + { + // expected + } + } +} diff --git a/ph-css/src/test/resources/testfiles/css30/good/artificial/test-mediaqueries-range.css b/ph-css/src/test/resources/testfiles/css30/good/artificial/test-mediaqueries-range.css new file mode 100644 index 00000000..217a5608 --- /dev/null +++ b/ph-css/src/test/resources/testfiles/css30/good/artificial/test-mediaqueries-range.css @@ -0,0 +1,57 @@ +/** + * Copyright (C) 2014-2026 Philip Helger (www.helger.com) + * philip[at]helger[dot]com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* Media Queries Level 4 range context - https://www.w3.org/TR/mediaqueries-4/#mq-range-context */ +@import url("print.css") (width >= 600px); + +@media (width >= 600px) { + .split { display: flex; } +} + +@media (width>=768px){.container{max-width:720px}} +@media (width<=576px){.container{max-width:100%}} + +@media (400px <= width <= 600px) { + .medium { display: block; } +} + +@media (600px > width > 400px) { + .medium-exclusive { display: block; } +} + +@media screen and (width > 768px), print and (height < 400px) { + .wide { display: block; } +} + +@media only screen and (width>=992px) and (hover: hover) { + .hover { display: block; } +} + +@media (aspect-ratio >= 16/9) and (resolution >= 2dppx) { + .hd { display: block; } +} + +@media (width >= calc(100px + 2em)) { + .calc { display: block; } +} + +@media (width = 600px) { + .exact { display: block; } +} + +@media not all and (width >= 600px) { + .narrow { display: block; } +} From ab97d2fc29f2706a1a621cba5cf730e5264b4b32 Mon Sep 17 00:00:00 2001 From: Mike Wiedenbauer Date: Wed, 26 Aug 2026 14:47:49 +0200 Subject: [PATCH 18/33] - add upstream PR creation --- .github/workflows/promote-to-upstream.yml | 69 +++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 .github/workflows/promote-to-upstream.yml diff --git a/.github/workflows/promote-to-upstream.yml b/.github/workflows/promote-to-upstream.yml new file mode 100644 index 00000000..b7b14c7c --- /dev/null +++ b/.github/workflows/promote-to-upstream.yml @@ -0,0 +1,69 @@ +name: Promote master to upstream + +on: + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + promote: + runs-on: ubuntu-latest + + steps: + - name: Checkout source master + uses: actions/checkout@v4 + with: + ref: master + fetch-depth: 0 + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Add upstream remote + run: | + git remote add upstream https://github.com/phax/ph-css.git + git fetch upstream master + + - name: Create promotion branch + run: | + git checkout -b promotion/master-to-upstream upstream/master + + - name: Merge source master + run: | + git merge --no-commit --no-ff origin/master + + - name: Exclude workflow files + run: | + git restore --source=upstream/master -- \ + .github/workflows/promote-develop.yml \ + .github/workflows/sync-upstream.yml \ + .github/workflows/validate-master-pr.yml + + - name: Commit promotion + run: | + git add -A + git commit \ + -m "Promote master to upstream/master" \ + || echo "Nothing to commit" + + - name: Push promotion branch to upstream + run: | + git push \ + https://x-access-token:${{ secrets.UPSTREAM_TOKEN }}@github.com/phax/ph-css.git \ + promotion/master-to-upstream \ + --force + + - name: Create upstream PR + env: + GH_TOKEN: ${{ secrets.UPSTREAM_TOKEN }} + run: | + gh pr create \ + --repo phax/ph-css.git \ + --base master \ + --head promotion/master-to-upstream \ + --title "Promote master to upstream" \ + --body "Automated promotion of master." From 8961a6b26916ffb1e8bb69d843847742d202d7ed Mon Sep 17 00:00:00 2001 From: Mike Wiedenbauer Date: Wed, 26 Aug 2026 14:55:43 +0200 Subject: [PATCH 19/33] - refinement --- .github/workflows/promote-to-upstream.yml | 163 +++++++++++++++++++--- 1 file changed, 142 insertions(+), 21 deletions(-) diff --git a/.github/workflows/promote-to-upstream.yml b/.github/workflows/promote-to-upstream.yml index b7b14c7c..e847d30a 100644 --- a/.github/workflows/promote-to-upstream.yml +++ b/.github/workflows/promote-to-upstream.yml @@ -1,69 +1,190 @@ -name: Promote master to upstream +name: Promote to upstream on: - workflow_dispatch: + pull_request: + types: + - opened + - reopened + - synchronize permissions: - contents: write + contents: read pull-requests: write +env: + TARGET_REPO: phax/ph-css.git + TARGET_BRANCH: master + SOURCE_BRANCH: master + PROMOTION_BRANCH: promotion/master-to-upstream + jobs: promote: + # Only react to: + # + # master -> upstream/master + # + # Ignore every other PR. + if: > + github.event.pull_request.head.ref == 'master' && + github.event.pull_request.base.ref == 'master' + runs-on: ubuntu-latest steps: + + # ------------------------------------------------------------ + # 1. Checkout source master + # ------------------------------------------------------------ + - name: Checkout source master uses: actions/checkout@v4 with: ref: master fetch-depth: 0 - - name: Configure Git + # ------------------------------------------------------------ + # 2. Configure git + # ------------------------------------------------------------ + + - name: Configure git run: | git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config user.email \ + "41898282+github-actions[bot]@users.noreply.github.com" + + # ------------------------------------------------------------ + # 3. Fetch target repository + # ------------------------------------------------------------ - name: Add upstream remote run: | - git remote add upstream https://github.com/phax/ph-css.git - git fetch upstream master + git remote add upstream \ + https://github.com/${TARGET_REPO}.git + + git fetch upstream ${TARGET_BRANCH} + + # ------------------------------------------------------------ + # 4. Create promotion branch from upstream/master + # ------------------------------------------------------------ - name: Create promotion branch run: | - git checkout -b promotion/master-to-upstream upstream/master + git checkout -B \ + "${PROMOTION_BRANCH}" \ + "upstream/${TARGET_BRANCH}" + + # ------------------------------------------------------------ + # 5. Merge source master + # ------------------------------------------------------------ - name: Merge source master run: | - git merge --no-commit --no-ff origin/master + git merge \ + --no-commit \ + --no-ff \ + "origin/${SOURCE_BRANCH}" + + # ------------------------------------------------------------ + # 6. Restore files that must NOT be promoted + # ------------------------------------------------------------ - - name: Exclude workflow files + - name: Restore upstream-owned workflow files run: | - git restore --source=upstream/master -- \ + git restore \ + --source="upstream/${TARGET_BRANCH}" \ + -- \ + .github/workflows/promote-to-upstream.yml \ .github/workflows/promote-develop.yml \ - .github/workflows/sync-upstream.yml \ - .github/workflows/validate-master-pr.yml + .github/workflows/validate-master-pr.yml \ + .github/workflows/sync-upstream.yml + + # ------------------------------------------------------------ + # 7. Commit promotion branch + # ------------------------------------------------------------ - name: Commit promotion run: | git add -A + git commit \ -m "Promote master to upstream/master" \ || echo "Nothing to commit" - - name: Push promotion branch to upstream + # ------------------------------------------------------------ + # 8. Push promotion branch to TARGET repository + # ------------------------------------------------------------ + + - name: Push promotion branch + env: + GH_TOKEN: ${{ secrets.UPSTREAM_TOKEN }} run: | git push \ - https://x-access-token:${{ secrets.UPSTREAM_TOKEN }}@github.com/phax/ph-css.git \ - promotion/master-to-upstream \ + "https://x-access-token:${GH_TOKEN}@github.com/${TARGET_REPO}.git" \ + "${PROMOTION_BRANCH}" \ --force - - name: Create upstream PR + # ------------------------------------------------------------ + # 9. Find manually-created PR + # ------------------------------------------------------------ + + - name: Find direct promotion PR + id: direct_pr + env: + GH_TOKEN: ${{ secrets.UPSTREAM_TOKEN }} + run: | + PR_NUMBER=$(gh pr list \ + --repo "${TARGET_REPO}" \ + --base "${TARGET_BRANCH}" \ + --head "${SOURCE_REPO}:master" \ + --state open \ + --json number \ + --jq '.[0].number // empty') + + echo "number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" + + # ------------------------------------------------------------ + # 10. Close manually-created direct PR + # ------------------------------------------------------------ + + - name: Close direct promotion PR + if: steps.direct_pr.outputs.number != '' + env: + GH_TOKEN: ${{ secrets.UPSTREAM_TOKEN }} + run: | + gh pr close \ + "${{ steps.direct_pr.outputs.number }}" \ + --repo "${TARGET_REPO}" \ + --comment \ + "This promotion is handled automatically. \ + A controlled promotion PR will be created." + + # ------------------------------------------------------------ + # 11. Create controlled promotion PR if necessary + # ------------------------------------------------------------ + + - name: Check promotion PR + id: promotion_pr + env: + GH_TOKEN: ${{ secrets.UPSTREAM_TOKEN }} + run: | + PR_NUMBER=$(gh pr list \ + --repo "${TARGET_REPO}" \ + --base "${TARGET_BRANCH}" \ + --head "${PROMOTION_BRANCH}" \ + --state open \ + --json number \ + --jq '.[0].number // empty') + + echo "number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" + + - name: Create promotion PR + if: steps.promotion_pr.outputs.number == '' env: GH_TOKEN: ${{ secrets.UPSTREAM_TOKEN }} run: | gh pr create \ - --repo phax/ph-css.git \ - --base master \ - --head promotion/master-to-upstream \ + --repo "${TARGET_REPO}" \ + --base "${TARGET_BRANCH}" \ + --head "${PROMOTION_BRANCH}" \ --title "Promote master to upstream" \ - --body "Automated promotion of master." + --body "This PR is automatically generated from source master." From 4193cf054c2b6b022261e6ed6314372ba2084325 Mon Sep 17 00:00:00 2001 From: Mike Wiedenbauer Date: Wed, 26 Aug 2026 14:57:27 +0200 Subject: [PATCH 20/33] - exclude the whole path --- .github/workflows/promote-to-upstream.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/promote-to-upstream.yml b/.github/workflows/promote-to-upstream.yml index e847d30a..178060e1 100644 --- a/.github/workflows/promote-to-upstream.yml +++ b/.github/workflows/promote-to-upstream.yml @@ -93,10 +93,7 @@ jobs: git restore \ --source="upstream/${TARGET_BRANCH}" \ -- \ - .github/workflows/promote-to-upstream.yml \ - .github/workflows/promote-develop.yml \ - .github/workflows/validate-master-pr.yml \ - .github/workflows/sync-upstream.yml + .github/workflows/ # ------------------------------------------------------------ # 7. Commit promotion branch From ef249d90ca4314f86a844be220185c8681f517f3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:59:38 +0000 Subject: [PATCH 21/33] Promote develop to master (#13) * [release] set version to "8.1.1-unblu-1" * Set version to "8.1.1-SNAPSHOT" * [release] set version to "8.1.2-unblu-1" * Set version on "8.1.2-SNAPSHOT" * [release] set version to "8.1.2-unblu-2" * Set version on "8.1.2-SNAPSHOT" * [release] set version to "8.1.2-unblu-3" * Set version on "8.1.2-SNAPSHOT" * #18758 media range operators * add validation and merge cascade actions * exclude pom.xml(s) on auto merge * - add upstream PR creation * - refinement * - exclude the whole path * Keep master-only files unchanged --------- Co-authored-by: Jeremie Bresson Co-authored-by: Mike Wiedenbauer Co-authored-by: stanislav.galfy Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/workflows/promote-to-upstream.yml | 187 ++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 .github/workflows/promote-to-upstream.yml diff --git a/.github/workflows/promote-to-upstream.yml b/.github/workflows/promote-to-upstream.yml new file mode 100644 index 00000000..178060e1 --- /dev/null +++ b/.github/workflows/promote-to-upstream.yml @@ -0,0 +1,187 @@ +name: Promote to upstream + +on: + pull_request: + types: + - opened + - reopened + - synchronize + +permissions: + contents: read + pull-requests: write + +env: + TARGET_REPO: phax/ph-css.git + TARGET_BRANCH: master + SOURCE_BRANCH: master + PROMOTION_BRANCH: promotion/master-to-upstream + +jobs: + promote: + # Only react to: + # + # master -> upstream/master + # + # Ignore every other PR. + if: > + github.event.pull_request.head.ref == 'master' && + github.event.pull_request.base.ref == 'master' + + runs-on: ubuntu-latest + + steps: + + # ------------------------------------------------------------ + # 1. Checkout source master + # ------------------------------------------------------------ + + - name: Checkout source master + uses: actions/checkout@v4 + with: + ref: master + fetch-depth: 0 + + # ------------------------------------------------------------ + # 2. Configure git + # ------------------------------------------------------------ + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email \ + "41898282+github-actions[bot]@users.noreply.github.com" + + # ------------------------------------------------------------ + # 3. Fetch target repository + # ------------------------------------------------------------ + + - name: Add upstream remote + run: | + git remote add upstream \ + https://github.com/${TARGET_REPO}.git + + git fetch upstream ${TARGET_BRANCH} + + # ------------------------------------------------------------ + # 4. Create promotion branch from upstream/master + # ------------------------------------------------------------ + + - name: Create promotion branch + run: | + git checkout -B \ + "${PROMOTION_BRANCH}" \ + "upstream/${TARGET_BRANCH}" + + # ------------------------------------------------------------ + # 5. Merge source master + # ------------------------------------------------------------ + + - name: Merge source master + run: | + git merge \ + --no-commit \ + --no-ff \ + "origin/${SOURCE_BRANCH}" + + # ------------------------------------------------------------ + # 6. Restore files that must NOT be promoted + # ------------------------------------------------------------ + + - name: Restore upstream-owned workflow files + run: | + git restore \ + --source="upstream/${TARGET_BRANCH}" \ + -- \ + .github/workflows/ + + # ------------------------------------------------------------ + # 7. Commit promotion branch + # ------------------------------------------------------------ + + - name: Commit promotion + run: | + git add -A + + git commit \ + -m "Promote master to upstream/master" \ + || echo "Nothing to commit" + + # ------------------------------------------------------------ + # 8. Push promotion branch to TARGET repository + # ------------------------------------------------------------ + + - name: Push promotion branch + env: + GH_TOKEN: ${{ secrets.UPSTREAM_TOKEN }} + run: | + git push \ + "https://x-access-token:${GH_TOKEN}@github.com/${TARGET_REPO}.git" \ + "${PROMOTION_BRANCH}" \ + --force + + # ------------------------------------------------------------ + # 9. Find manually-created PR + # ------------------------------------------------------------ + + - name: Find direct promotion PR + id: direct_pr + env: + GH_TOKEN: ${{ secrets.UPSTREAM_TOKEN }} + run: | + PR_NUMBER=$(gh pr list \ + --repo "${TARGET_REPO}" \ + --base "${TARGET_BRANCH}" \ + --head "${SOURCE_REPO}:master" \ + --state open \ + --json number \ + --jq '.[0].number // empty') + + echo "number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" + + # ------------------------------------------------------------ + # 10. Close manually-created direct PR + # ------------------------------------------------------------ + + - name: Close direct promotion PR + if: steps.direct_pr.outputs.number != '' + env: + GH_TOKEN: ${{ secrets.UPSTREAM_TOKEN }} + run: | + gh pr close \ + "${{ steps.direct_pr.outputs.number }}" \ + --repo "${TARGET_REPO}" \ + --comment \ + "This promotion is handled automatically. \ + A controlled promotion PR will be created." + + # ------------------------------------------------------------ + # 11. Create controlled promotion PR if necessary + # ------------------------------------------------------------ + + - name: Check promotion PR + id: promotion_pr + env: + GH_TOKEN: ${{ secrets.UPSTREAM_TOKEN }} + run: | + PR_NUMBER=$(gh pr list \ + --repo "${TARGET_REPO}" \ + --base "${TARGET_BRANCH}" \ + --head "${PROMOTION_BRANCH}" \ + --state open \ + --json number \ + --jq '.[0].number // empty') + + echo "number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" + + - name: Create promotion PR + if: steps.promotion_pr.outputs.number == '' + env: + GH_TOKEN: ${{ secrets.UPSTREAM_TOKEN }} + run: | + gh pr create \ + --repo "${TARGET_REPO}" \ + --base "${TARGET_BRANCH}" \ + --head "${PROMOTION_BRANCH}" \ + --title "Promote master to upstream" \ + --body "This PR is automatically generated from source master." From 45e2e85887e00c55e34aa357c7408112e2e74206 Mon Sep 17 00:00:00 2001 From: Mike Wiedenbauer Date: Wed, 26 Aug 2026 16:20:34 +0200 Subject: [PATCH 22/33] - use PAT --- .github/workflows/promote-develop.yml | 4 ++-- .github/workflows/promote-to-upstream.yml | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/promote-develop.yml b/.github/workflows/promote-develop.yml index 04c8f62a..172fb1c5 100644 --- a/.github/workflows/promote-develop.yml +++ b/.github/workflows/promote-develop.yml @@ -76,7 +76,7 @@ jobs: - name: Create promotion PR env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ secrets.PROMOTION_APP_TOKEN }} run: | PR=$(gh pr list \ --base master \ @@ -97,7 +97,7 @@ jobs: - name: Enable auto-merge env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ secrets.PROMOTION_APP_TOKEN }} run: | PR=$(gh pr list \ --base master \ diff --git a/.github/workflows/promote-to-upstream.yml b/.github/workflows/promote-to-upstream.yml index 178060e1..6fb31f4d 100644 --- a/.github/workflows/promote-to-upstream.yml +++ b/.github/workflows/promote-to-upstream.yml @@ -12,8 +12,9 @@ permissions: pull-requests: write env: - TARGET_REPO: phax/ph-css.git + TARGET_REPO: phax/ph-css TARGET_BRANCH: master + SOURCE_REPO: unblu/ph-css SOURCE_BRANCH: master PROMOTION_BRANCH: promotion/master-to-upstream From 8088351e23e5eae533577f9e280dc4b51972236d Mon Sep 17 00:00:00 2001 From: Jeremie Bresson Date: Wed, 26 Aug 2026 17:01:39 +0200 Subject: [PATCH 23/33] Augment the CLAUDE.md to document the unblu fork --- CLAUDE.md | 45 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0ee3b922..d26fbdc4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,48 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -ph-css is a Java-based CSS 3 parser and builder library (v8.1.2-SNAPSHOT). It parses CSS into a Java object model, supports traversal/modification via the visitor pattern, and can serialize back to CSS. The companion module `ph-csscompress-maven-plugin` provides build-time CSS compression. +ph-css is a Java-based CSS 3 parser and builder library (v8.2.2-SNAPSHOT). It parses CSS into a Java object model, supports traversal/modification via the visitor pattern, and can serialize back to CSS. The companion module `ph-csscompress-maven-plugin` provides build-time CSS compression. + +## Fork Context + +This repository is unblu's fork of [phax/ph-css](https://github.com/phax/ph-css). It has two long-lived branches: + +| Branch | Content | +|--------|---------| +| `master` | 1:1 mirror of the upstream `phax/ph-css` `master`. Nothing is committed here directly. | +| `develop` | Default working branch, holding everything this fork adds on top of upstream. Fork releases are tagged here. | + +Work branches (`feature/...`, `-`) are created from `develop` and merged back into `develop` by pull request. Upstream changes land on `master` and are merged from there into `develop`. + +### Branch-specific POM coordinates + +`develop` carries fork-specific coordinates so the produced artifacts never collide with the upstream ones: + +- `groupId`: `unblu.patched.com.helger` (upstream uses `com.helger`) +- `version`: `8.2.2-SNAPSHOT`, released as `-unblu-` (for example `8.1.2-unblu-3`) + +`master` keeps the upstream coordinates. That is why the promotion workflow restores the three `pom.xml` files from `master` when it merges `develop` into `master`. + +### Automation (`.github/workflows/`) + +| Workflow | Trigger | Effect | +|----------|---------|--------| +| `sync-upstream.yml` | nightly + manual | Force-pushes `upstream/master` onto `sync/upstream-master` and opens a PR against `master`. | +| `promote-develop.yml` | a PR is merged into `develop`, or a `develop` -> `master` PR is opened | Rebuilds `promotion/develop-to-master` from `master`, merges `develop` into it, restores the `master` POMs, opens the PR against `master` and enables squash auto-merge. A manually opened `develop` -> `master` PR is closed in favour of it. | +| `validate-master-pr.yml` | PR targeting `master` | Only `promotion/develop-to-master` and `sync/upstream-master` may target `master`; anything else - including a direct `develop` PR - fails. | +| `release-to-slack.yml` | GitHub release published | Announces the release on a chat webhook. | +| `maven.yml` | push / PR | Upstream's build matrix (Java 17, 21, 25). | + +In short: upstream changes flow in via `master` -> `develop`, and changes made here flow out via `develop` -> `promotion/develop-to-master` -> `master`, from where they can be proposed upstream. + +### Releasing + +Releases are cut from `develop`, never from `master`: + +1. Merge the work branch into `develop`. +2. Commit the version bump to `-unblu-`. +3. Tag that commit and publish the GitHub release. +4. Build with Java 17+ (`mvn clean install`) and publish the resulting `ph-css-parent-pom` and `ph-css` artifacts (POM, JAR, sources JAR) to the consuming project's internal Maven repository. Nothing from this fork is published to Maven Central. ## Build Commands @@ -73,7 +114,7 @@ JavaCC generates parser sources into `target/generated-sources/jjtree` and `targ ### Key Dependencies -- `ph-commons` (12.1.5) - Collection types (`ICommonsList`, `CommonsArrayList`), I/O utilities, type conversion +- `ph-commons` (12.3.5) - Collection types (`ICommonsList`, `CommonsArrayList`), I/O utilities, type conversion - `ph-javacc-maven-plugin` (5.0.1) - Parser generation from `.jjt` grammars - JUnit 4 for tests From de20ca959a5beebd4a7b5d99810b4a0ec8f1dc70 Mon Sep 17 00:00:00 2001 From: Mike Wiedenbauer Date: Wed, 26 Aug 2026 17:10:55 +0200 Subject: [PATCH 24/33] - use Github App --- .github/workflows/promote-develop.yml | 180 ++++++++++++++++++++------ 1 file changed, 141 insertions(+), 39 deletions(-) diff --git a/.github/workflows/promote-develop.yml b/.github/workflows/promote-develop.yml index 172fb1c5..82ae31f3 100644 --- a/.github/workflows/promote-develop.yml +++ b/.github/workflows/promote-develop.yml @@ -14,8 +14,22 @@ permissions: contents: write pull-requests: write +env: + REPOSITORY: unblu/ph-css + SOURCE_BRANCH: develop + TARGET_BRANCH: master + PROMOTION_BRANCH: promotion/develop-to-master + jobs: promote: + # Two situations trigger this job: + # + # 1. A develop PR was merged. + # -> create/update the promotion PR + # + # 2. Somebody manually created develop -> master. + # -> create the controlled promotion PR and close the manual one. + # if: > ( github.event.pull_request.base.ref == 'develop' && @@ -34,29 +48,77 @@ jobs: runs-on: ubuntu-latest steps: - - name: Checkout + + # ------------------------------------------------------------ + # Checkout + # ------------------------------------------------------------ + + - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 0 + # ------------------------------------------------------------ + # Generate short-lived GitHub App installation token + # ------------------------------------------------------------ + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.PH_CSS_PROMOTION_APP_ID }} + private-key: ${{ secrets.PH_CSS_PROMOTION_APP_KEY }} + owner: unblu + repositories: ph-css + permission-contents: write + permission-pull-requests: write + + # ------------------------------------------------------------ + # Configure Git + # ------------------------------------------------------------ + - name: Configure Git run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config user.name "ph-css-promotion[bot]" + git config user.email "ph-css-promotion[bot]@users.noreply.github.com" + + # ------------------------------------------------------------ + # Fetch branches + # ------------------------------------------------------------ - name: Fetch branches run: | git fetch origin master git fetch origin develop + # ------------------------------------------------------------ + # Create promotion branch from master + # ------------------------------------------------------------ + - name: Create promotion branch run: | - git checkout -B promotion/develop-to-master origin/master - git merge origin/develop --no-edit + git checkout -B \ + "${PROMOTION_BRANCH}" \ + "origin/${TARGET_BRANCH}" + + # ------------------------------------------------------------ + # Merge develop into promotion branch + # ------------------------------------------------------------ + + - name: Merge develop + run: | + git merge \ + --no-commit \ + --no-ff \ + "origin/${SOURCE_BRANCH}" + + # ------------------------------------------------------------ + # Restore files that must remain master-only + # ------------------------------------------------------------ - name: Restore master-only files run: | - git checkout origin/master -- \ + git checkout "origin/${TARGET_BRANCH}" -- \ ph-css/pom.xml \ ph-csscompress-maven-plugin/pom.xml \ pom.xml @@ -70,53 +132,93 @@ jobs: git commit -m "Keep master-only files unchanged" fi + # ------------------------------------------------------------ + # Push promotion branch + # + # IMPORTANT: + # Use the GitHub App token, NOT github.token. + # ------------------------------------------------------------ + - name: Push promotion branch + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | - git push --force origin promotion/develop-to-master + git push \ + "https://x-access-token:${GH_TOKEN}@github.com/${REPOSITORY}.git" \ + "${PROMOTION_BRANCH}" \ + --force - - name: Create promotion PR + # ------------------------------------------------------------ + # Find manually-created develop -> master PR + # ------------------------------------------------------------ + + - name: Find direct develop → master PR + id: direct-pr env: - GH_TOKEN: ${{ secrets.PROMOTION_APP_TOKEN }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | - PR=$(gh pr list \ - --base master \ - --head promotion/develop-to-master \ + PR_NUMBER=$(gh pr list \ + --repo "${REPOSITORY}" \ + --base "${TARGET_BRANCH}" \ + --head "${SOURCE_BRANCH}" \ --state open \ --json number \ - --jq '.[0].number') - - if [ -z "$PR" ]; then - PR=$(gh pr create \ - --base master \ - --head promotion/develop-to-master \ - --title "Promote develop to master" \ - --body "Automatic promotion of develop to master.") - fi + --jq '.[0].number // empty') - echo "Promotion PR: $PR" + echo "number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" - - name: Enable auto-merge + # ------------------------------------------------------------ + # Close manually-created develop -> master PR + # ------------------------------------------------------------ + + - name: Close direct develop → master PR + if: steps.direct-pr.outputs.number != '' env: - GH_TOKEN: ${{ secrets.PROMOTION_APP_TOKEN }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | - PR=$(gh pr list \ - --base master \ - --head promotion/develop-to-master \ + gh pr close \ + "${{ steps.direct-pr.outputs.number }}" \ + --repo "${REPOSITORY}" \ + --comment \ + "This direct promotion PR is replaced by the automated promotion PR." + + # ------------------------------------------------------------ + # Check whether promotion PR already exists + # ------------------------------------------------------------ + + - name: Find promotion PR + id: promotion-pr + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + PR_NUMBER=$(gh pr list \ + --repo "${REPOSITORY}" \ + --base "${TARGET_BRANCH}" \ + --head "${PROMOTION_BRANCH}" \ --state open \ --json number \ - --jq '.[0].number') + --jq '.[0].number // empty') - gh pr merge "$PR" \ - --auto \ - --squash + echo "number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" - - name: Close manual develop → master PR - if: > - github.event.pull_request.base.ref == 'master' && - github.event.pull_request.head.ref == 'develop' + # ------------------------------------------------------------ + # Create promotion PR + # + # IMPORTANT: + # This PR is created with the GitHub App token. + # Do NOT replace it with github.token. + # ------------------------------------------------------------ + + - name: Create promotion PR + if: steps.promotion-pr.outputs.number == '' env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | - gh pr close \ - "${{ github.event.pull_request.number }}" \ - --comment "Replaced by the automatic promotion PR." + PR_NUMBER=$(gh pr create \ + --repo "${REPOSITORY}" \ + --base "${TARGET_BRANCH}" \ + --head "${PROMOTION_BRANCH}" \ + --title "Promote develop to master" \ + --body "Automated promotion of develop to master.") + + echo "Created promotion PR: ${PR_NUMBER}" From 7ef50405f6cfbeaa8d5de2198634ae431825d062 Mon Sep 17 00:00:00 2001 From: Mike Wiedenbauer Date: Wed, 26 Aug 2026 17:24:28 +0200 Subject: [PATCH 25/33] - narrow pr types --- .github/workflows/validate-master-pr.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/validate-master-pr.yml b/.github/workflows/validate-master-pr.yml index ec1d7385..d584c941 100644 --- a/.github/workflows/validate-master-pr.yml +++ b/.github/workflows/validate-master-pr.yml @@ -2,6 +2,10 @@ name: Validate master PR on: pull_request: + types: + - opened + - reopened + - synchronize branches: - master @@ -12,8 +16,10 @@ jobs: steps: - name: Validate source branch + env: + SOURCE: ${{ github.head_ref }} run: | - SOURCE="${{ github.head_ref }}" + echo "Source branch: ${SOURCE}" case "$SOURCE" in promotion/develop-to-master) @@ -25,13 +31,13 @@ jobs: ;; develop) - echo "ERROR: Direct develop -> master PR." + echo "::error::Direct develop -> master PR." echo "The promotion workflow must be used." exit 1 ;; *) - echo "ERROR: Invalid source branch: $SOURCE" + echo "::error::Invalid source branch: ${SOURCE}" exit 1 ;; esac From bc295f1906ebd54ebecd5aefdf0dd2a9b0185835 Mon Sep 17 00:00:00 2001 From: Mike Wiedenbauer Date: Wed, 26 Aug 2026 23:04:40 +0200 Subject: [PATCH 26/33] - enable auto-merge on promotion pr - exclude CLAUDE.md from upstream PR --- .github/workflows/promote-develop.yml | 10 ++++++++++ .github/workflows/promote-to-upstream.yml | 3 ++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/promote-develop.yml b/.github/workflows/promote-develop.yml index 82ae31f3..d5a9ef48 100644 --- a/.github/workflows/promote-develop.yml +++ b/.github/workflows/promote-develop.yml @@ -222,3 +222,13 @@ jobs: --body "Automated promotion of develop to master.") echo "Created promotion PR: ${PR_NUMBER}" + + # ------------------------------------------------------------ + # Enable auto-merge for promotion PR + # ------------------------------------------------------------ + + - name: Enable auto-merge + run: | + gh pr merge "${PROMOTION_BRANCH}" \ + --auto \ + --merge \ No newline at end of file diff --git a/.github/workflows/promote-to-upstream.yml b/.github/workflows/promote-to-upstream.yml index 6fb31f4d..a184deae 100644 --- a/.github/workflows/promote-to-upstream.yml +++ b/.github/workflows/promote-to-upstream.yml @@ -94,7 +94,8 @@ jobs: git restore \ --source="upstream/${TARGET_BRANCH}" \ -- \ - .github/workflows/ + .github/workflows/ \ + CLAUDE.md # ------------------------------------------------------------ # 7. Commit promotion branch From 3c885c31f282e18569f461cf9eed86901d3337c2 Mon Sep 17 00:00:00 2001 From: Mike Wiedenbauer Date: Wed, 26 Aug 2026 23:14:25 +0200 Subject: [PATCH 27/33] - sync master into develop workflow --- .github/workflows/sync-master-to-develop.yml | 37 ++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/sync-master-to-develop.yml diff --git a/.github/workflows/sync-master-to-develop.yml b/.github/workflows/sync-master-to-develop.yml new file mode 100644 index 00000000..c5a69f00 --- /dev/null +++ b/.github/workflows/sync-master-to-develop.yml @@ -0,0 +1,37 @@ +name: Sync master back to develop + +on: + pull_request: + types: [closed] + branches: + - master + +permissions: + contents: write + pull-requests: write + +jobs: + sync-master-to-develop: + if: > + github.event.pull_request.merged == true && + github.event.pull_request.head.ref == 'promotion/develop-to-master' + + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Sync master into develop + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git fetch origin master develop + + git checkout develop + git merge origin/master --no-edit + + git push origin develop From 88efc0990d580e4757d42923b7d1139acb2882f8 Mon Sep 17 00:00:00 2001 From: Mike Wiedenbauer Date: Wed, 26 Aug 2026 23:26:10 +0200 Subject: [PATCH 28/33] - correct enabling of auto-merge --- .github/workflows/promote-develop.yml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/promote-develop.yml b/.github/workflows/promote-develop.yml index d5a9ef48..4359a899 100644 --- a/.github/workflows/promote-develop.yml +++ b/.github/workflows/promote-develop.yml @@ -228,7 +228,19 @@ jobs: # ------------------------------------------------------------ - name: Enable auto-merge + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | - gh pr merge "${PROMOTION_BRANCH}" \ + PR_NUMBER="${{ steps.promotion-pr.outputs.number }}" + + if [ -z "${PR_NUMBER}" ]; then + PR_NUMBER="${{ steps.create-promotion-pr.outputs.number }}" + fi + + echo "Enabling auto-merge for PR #${PR_NUMBER}" + + gh pr merge \ + "${PR_NUMBER}" \ + --repo "${REPOSITORY}" \ --auto \ - --merge \ No newline at end of file + --merge From 79a47c19309d88fa0635f1fb714f49aa2eb7ac51 Mon Sep 17 00:00:00 2001 From: Mike Wiedenbauer Date: Thu, 27 Aug 2026 09:51:41 +0200 Subject: [PATCH 29/33] - preserve develop pom.xml --- .github/workflows/sync-master-to-develop.yml | 92 ++++++++++++++++++-- 1 file changed, 85 insertions(+), 7 deletions(-) diff --git a/.github/workflows/sync-master-to-develop.yml b/.github/workflows/sync-master-to-develop.yml index c5a69f00..c083e6ff 100644 --- a/.github/workflows/sync-master-to-develop.yml +++ b/.github/workflows/sync-master-to-develop.yml @@ -19,19 +19,97 @@ jobs: runs-on: ubuntu-latest steps: + # ------------------------------------------------------------ + # Checkout + # ------------------------------------------------------------ + - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Sync master into develop + # ------------------------------------------------------------ + # Generate GitHub App installation token + # ------------------------------------------------------------ + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.PH_CSS_PROMOTION_APP_ID }} + private-key: ${{ secrets.PH_CSS_PROMOTION_APP_KEY }} + owner: unblu + repositories: ph-css + permission-contents: write + permission-pull-requests: write + + # ------------------------------------------------------------ + # Configure Git + # ------------------------------------------------------------ + + - name: Configure Git run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config user.name "ph-css-promotion[bot]" + git config user.email "ph-css-promotion[bot]@users.noreply.github.com" - git fetch origin master develop + # ------------------------------------------------------------ + # Fetch branches + # ------------------------------------------------------------ + + - name: Fetch branches + run: | + git fetch origin master + git fetch origin develop + + # ------------------------------------------------------------ + # Checkout develop + # ------------------------------------------------------------ + + - name: Checkout develop + run: | + git checkout -B develop origin/develop - git checkout develop - git merge origin/master --no-edit + # ------------------------------------------------------------ + # Merge master into develop + # ------------------------------------------------------------ - git push origin develop + - name: Merge master + run: | + git merge \ + --no-commit \ + --no-ff \ + origin/master + + # ------------------------------------------------------------ + # Restore develop-only POM files + # + # These files must always remain exactly as they were on develop. + # ------------------------------------------------------------ + + - name: Restore develop-only POM files + run: | + git checkout origin/develop -- \ + ph-css/pom.xml \ + ph-csscompress-maven-plugin/pom.xml \ + pom.xml + + git add \ + ph-css/pom.xml \ + ph-csscompress-maven-plugin/pom.xml \ + pom.xml + + if ! git diff --cached --quiet; then + git commit -m "Keep develop-only files unchanged" + fi + + # ------------------------------------------------------------ + # Push synchronized develop + # ------------------------------------------------------------ + + - name: Push develop + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + git push \ + "https://x-access-token:${GH_TOKEN}@github.com/unblu/ph-css.git" \ + develop From 7257c56aa0fe72d42d034cf098ecceb62c25ba51 Mon Sep 17 00:00:00 2001 From: Jeremie Bresson Date: Thu, 27 Aug 2026 09:53:46 +0200 Subject: [PATCH 30/33] Restore the groupId change --- CLAUDE.md | 25 +++++++++++++++++++++++++ ph-css/pom.xml | 2 +- ph-csscompress-maven-plugin/pom.xml | 5 ++--- pom.xml | 5 +++-- 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d26fbdc4..fd247739 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,6 +26,31 @@ Work branches (`feature/...`, `-`) are created from `develop` and m `master` keeps the upstream coordinates. That is why the promotion workflow restores the three `pom.xml` files from `master` when it merges `develop` into `master`. +### POM coordinates and the project version + +The fork renames the coordinates to `unblu.patched.com.helger` in all three POMs *and* in the +`dependencyManagement` block of the root POM. Keeping the managed entries renamed matters: they are what +lets `ph-csscompress-maven-plugin` depend on `ph-css` without a `` of its own, exactly as upstream +does. The project version therefore appears exactly three times - in `pom.xml`, and in the `` block +of each of the two modules: + +```bash +grep -rn -- "-SNAPSHOT" --include=pom.xml . +``` + +If a managed entry ever falls back to `com.helger` - for instance through a `master` -> `develop` merge +that brings upstream's POMs back - the plugin module stops resolving and has to spell its `ph-css` version +out again. Such a hardcoded version does not follow the next upstream version bump, because the merge keeps +our side of the dependency block, and the build then fails on the last module with: + +``` +[ERROR] Failed to execute goal on project ph-csscompress-maven-plugin: Could not resolve dependencies +[ERROR] dependency: unblu.patched.com.helger:ph-css:jar:8.1.2-SNAPSHOT (compile) +``` + +So after merging `master` into `develop`, check both the three versions and the group ids of the two +managed entries. + ### Automation (`.github/workflows/`) | Workflow | Trigger | Effect | diff --git a/ph-css/pom.xml b/ph-css/pom.xml index d6804c08..f7982e26 100644 --- a/ph-css/pom.xml +++ b/ph-css/pom.xml @@ -20,7 +20,7 @@ 4.0.0 - com.helger + unblu.patched.com.helger ph-css-parent-pom 8.2.2-SNAPSHOT diff --git a/ph-csscompress-maven-plugin/pom.xml b/ph-csscompress-maven-plugin/pom.xml index 66bf4749..74c4fc1b 100644 --- a/ph-csscompress-maven-plugin/pom.xml +++ b/ph-csscompress-maven-plugin/pom.xml @@ -20,11 +20,10 @@ 4.0.0 - com.helger + unblu.patched.com.helger ph-css-parent-pom 8.2.2-SNAPSHOT - com.helger.maven ph-csscompress-maven-plugin maven-plugin ph-csscompress-maven-plugin @@ -74,7 +73,7 @@ - com.helger + unblu.patched.com.helger ph-css diff --git a/pom.xml b/pom.xml index 76ee6596..5e49797e 100644 --- a/pom.xml +++ b/pom.xml @@ -24,6 +24,7 @@ parent-pom 3.1.0 + unblu.patched.com.helger ph-css-parent-pom 8.2.2-SNAPSHOT pom @@ -72,12 +73,12 @@ - com.helger + unblu.patched.com.helger ph-css ${project.version} - com.helger + unblu.patched.com.helger ph-csscompress-maven-plugin ${project.version} From 48aa3059ab308b7295c500828d3f050e4ecf4655 Mon Sep 17 00:00:00 2001 From: Mike Wiedenbauer Date: Thu, 27 Aug 2026 10:34:03 +0200 Subject: [PATCH 31/33] - always sync master back to develop --- .github/workflows/sync-master-to-develop.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sync-master-to-develop.yml b/.github/workflows/sync-master-to-develop.yml index c083e6ff..d6c58575 100644 --- a/.github/workflows/sync-master-to-develop.yml +++ b/.github/workflows/sync-master-to-develop.yml @@ -98,9 +98,13 @@ jobs: ph-csscompress-maven-plugin/pom.xml \ pom.xml - if ! git diff --cached --quiet; then - git commit -m "Keep develop-only files unchanged" - fi + # ------------------------------------------------------------ + # Commit synchronization + # ------------------------------------------------------------ + + - name: Commit synchronization + run: | + git commit -m "Sync master back to develop" # ------------------------------------------------------------ # Push synchronized develop From 91f980717b457870a4b94ad862997cd4eecb3999 Mon Sep 17 00:00:00 2001 From: Mike Wiedenbauer Date: Thu, 27 Aug 2026 10:53:16 +0200 Subject: [PATCH 32/33] - preserve title + body from the original PR --- .github/workflows/promote-to-upstream.yml | 46 ++++++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/.github/workflows/promote-to-upstream.yml b/.github/workflows/promote-to-upstream.yml index a184deae..4056d29e 100644 --- a/.github/workflows/promote-to-upstream.yml +++ b/.github/workflows/promote-to-upstream.yml @@ -180,10 +180,52 @@ jobs: if: steps.promotion_pr.outputs.number == '' env: GH_TOKEN: ${{ secrets.UPSTREAM_TOKEN }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + SOURCE_PR_NUMBER: ${{ github.event.pull_request.number }} run: | + cat > /tmp/pr-body.md < /tmp/pr-body.md < Date: Thu, 27 Aug 2026 11:41:27 +0200 Subject: [PATCH 33/33] - change env var for the push to ensure the PAT is used here --- .github/workflows/promote-to-upstream.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/promote-to-upstream.yml b/.github/workflows/promote-to-upstream.yml index 4056d29e..aa10e3dc 100644 --- a/.github/workflows/promote-to-upstream.yml +++ b/.github/workflows/promote-to-upstream.yml @@ -115,10 +115,10 @@ jobs: - name: Push promotion branch env: - GH_TOKEN: ${{ secrets.UPSTREAM_TOKEN }} + UPSTREAM_TOKEN: ${{ secrets.UPSTREAM_TOKEN }} run: | git push \ - "https://x-access-token:${GH_TOKEN}@github.com/${TARGET_REPO}.git" \ + "https://x-access-token:${UPSTREAM_TOKEN}@github.com/${TARGET_REPO}.git" \ "${PROMOTION_BRANCH}" \ --force