@thejaredwilcurt/csslop 0.0.29 → 0.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/src/rules/optimize.js +439 -25
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@thejaredwilcurt/csslop",
3
3
  "main": "index.js",
4
4
  "type": "module",
5
- "version": "0.0.29",
5
+ "version": "0.0.30",
6
6
  "description": "Experimental CSS minification",
7
7
  "scripts": {
8
8
  "prestart": "node ./scripts/prestart.js",
@@ -58,7 +58,7 @@
58
58
  "devEngines": {
59
59
  "runtime": {
60
60
  "name": "node",
61
- "version": "26.7.0"
61
+ "version": "26.8.1"
62
62
  },
63
63
  "packageManager": {
64
64
  "name": "npm",
@@ -2,6 +2,10 @@
2
2
  * @file Optimizes CSS rule structures by merging selectors, deduplicating keyframes, nesting flat rules, and consolidating `@media` and `@layer` blocks.
3
3
  */
4
4
 
5
+ import {
6
+ expandToLeafProperties,
7
+ getOverridesOf
8
+ } from '../declarations/config.js';
5
9
  import { escapeRegexString } from '../utilities.js';
6
10
 
7
11
  import {
@@ -722,6 +726,53 @@ function maxSelectorSpecificity (selectorList) {
722
726
  return maximum;
723
727
  }
724
728
 
729
+ /**
730
+ * Returns the strongest specificity a rule can match with, which is the highest
731
+ * of its individual selectors.
732
+ *
733
+ * @param {Array} selectors The rule's selector strings.
734
+ * @return {Array} The [ids, classes, types] specificity tuple.
735
+ */
736
+ function maximumRuleSpecificity (selectors) {
737
+ return maxSelectorSpecificity((selectors || []).join(','));
738
+ }
739
+
740
+ /**
741
+ * Returns the weakest specificity a rule's declarations are applied with. Every
742
+ * selector of a top-level rule applies the same declarations under its own
743
+ * specificity, so the weakest selector decides whether another rule is able to
744
+ * override the rule as a whole.
745
+ *
746
+ * @param {Array} selectors The rule's selector strings.
747
+ * @return {Array} The [ids, classes, types] specificity tuple.
748
+ */
749
+ function minimumRuleSpecificity (selectors) {
750
+ let minimum = null;
751
+ for (const selector of splitSelectorListTopLevel((selectors || []).join(','))) {
752
+ const specificity = computeSpecificity(selector);
753
+ if (!minimum || compareSpecificity(specificity, minimum) < 0) {
754
+ minimum = specificity;
755
+ }
756
+ }
757
+ return minimum || [0, 0, 0];
758
+ }
759
+
760
+ /**
761
+ * Adds two specificity tuples together, which is how a nested selector combines
762
+ * with the parent selector it only ever matches through.
763
+ *
764
+ * @param {Array} first The first [ids, classes, types] tuple.
765
+ * @param {Array} second The second [ids, classes, types] tuple.
766
+ * @return {Array} The summed specificity tuple.
767
+ */
768
+ function addSpecificity (first, second) {
769
+ return [
770
+ first[0] + second[0],
771
+ first[1] + second[1],
772
+ first[2] + second[2]
773
+ ];
774
+ }
775
+
725
776
  /**
726
777
  * Builds a normalized signature of a rule's declaration body, or null when the
727
778
  * rule contains anything other than plain declarations (e.g. nested rules).
@@ -828,44 +879,407 @@ function mergeIdenticalNestedRules (rules) {
828
879
  }
829
880
 
830
881
  /**
831
- * Merges rules with identical normalized selectors by combining their declarations. Non-rule entries (like `@media`) break the merge window.
882
+ * The property that rewrites every other standard property, and so conflicts
883
+ * with any declaration it is ordered against.
884
+ *
885
+ * @type {string}
886
+ */
887
+ const RESET_ALL_PROPERTY = 'all';
888
+
889
+ /**
890
+ * Index key standing for "declares anything at all", used to order a rule that
891
+ * sets `all` against every other declaration.
892
+ *
893
+ * @type {symbol}
894
+ */
895
+ const ANY_PROPERTY = Symbol('any property');
896
+
897
+ /**
898
+ * The leaf properties each declared property can write to, computed on first
899
+ * use. The shorthand tables never change, so a property always expands the same
900
+ * way.
901
+ *
902
+ * @type {Map<string, Set<string>>}
903
+ */
904
+ const overridablePropertiesByProperty = new Map();
905
+
906
+ /**
907
+ * Expands a declared property into every leaf longhand it can write to,
908
+ * including the extras a shorthand resets beyond its own longhands. Two
909
+ * declarations can only override one another when these sets intersect, which
910
+ * is what makes `margin` conflict with `margin-top` but not with `color`.
911
+ *
912
+ * @param {string} property The declared property name.
913
+ * @return {Set} The leaf property names the declaration writes to.
914
+ */
915
+ function expandToOverridableProperties (property) {
916
+ const cachedProperties = overridablePropertiesByProperty.get(property);
917
+ if (cachedProperties) {
918
+ return cachedProperties;
919
+ }
920
+ const overridableProperties = new Set(expandToLeafProperties(property));
921
+ for (const resetProperty of getOverridesOf(property)) {
922
+ for (const leafProperty of expandToLeafProperties(resetProperty)) {
923
+ overridableProperties.add(leafProperty);
924
+ }
925
+ }
926
+ overridablePropertiesByProperty.set(property, overridableProperties);
927
+ return overridableProperties;
928
+ }
929
+
930
+ /**
931
+ * Whether a property name is a custom property, which shorthands and `all`
932
+ * never reset.
933
+ *
934
+ * @param {string} property The leaf property name.
935
+ * @return {boolean} True when the property is a custom property.
936
+ */
937
+ function isCustomProperty (property) {
938
+ return property.startsWith('--');
939
+ }
940
+
941
+ /**
942
+ * @typedef {object} RuleWriteProfile
943
+ * @property {Map} specificityByProperty The strongest specificity each leaf property is written with.
944
+ * @property {Array} weakestSpecificity The weakest specificity the rule's declarations are applied with.
945
+ * @property {Array} strongestSpecificity The strongest specificity anything in the rule is written with.
946
+ * @property {boolean} writesStandardProperty Whether the rule writes any property other than a custom property.
947
+ */
948
+
949
+ /**
950
+ * Notes in a profile that a leaf property is written with a specificity,
951
+ * keeping only the strongest specificity seen for it.
952
+ *
953
+ * @param {RuleWriteProfile} profile The profile to extend.
954
+ * @param {string} property The leaf property name.
955
+ * @param {Array} specificity The specificity the property is written with.
956
+ */
957
+ function addWrittenProperty (profile, property, specificity) {
958
+ const strongestSoFar = profile.specificityByProperty.get(property);
959
+ if (!strongestSoFar || compareSpecificity(specificity, strongestSoFar) > 0) {
960
+ profile.specificityByProperty.set(property, specificity);
961
+ }
962
+ if (compareSpecificity(specificity, profile.strongestSpecificity) > 0) {
963
+ profile.strongestSpecificity = specificity;
964
+ }
965
+ profile.writesStandardProperty = profile.writesStandardProperty || !isCustomProperty(property);
966
+ }
967
+
968
+ /**
969
+ * Adds every leaf property a rule's declarations write to into a profile,
970
+ * descending into nested rules, whose declarations only ever match through the
971
+ * parent and so carry the specificity of both selectors.
972
+ *
973
+ * @param {RuleWriteProfile} profile The profile to extend.
974
+ * @param {object} rule The AST rule node to read declarations from.
975
+ * @param {Array} specificity The specificity the rule's own declarations apply with.
976
+ */
977
+ function addWrittenProperties (profile, rule, specificity) {
978
+ for (const declaration of rule.declarations || []) {
979
+ if (declaration.type === 'rule') {
980
+ const nestedSpecificity = addSpecificity(specificity, maximumRuleSpecificity(declaration.selectors));
981
+ addWrittenProperties(profile, declaration, nestedSpecificity);
982
+ } else if (declaration.type === 'declaration' && declaration.property) {
983
+ for (const leafProperty of expandToOverridableProperties(declaration.property)) {
984
+ addWrittenProperty(profile, leafProperty, specificity);
985
+ }
986
+ }
987
+ }
988
+ }
989
+
990
+ /**
991
+ * Summarizes which properties a rule writes to and how strongly, which is
992
+ * everything the cascade needs to know about a rule to decide whether it may be
993
+ * reordered against another one.
994
+ *
995
+ * @param {object} rule The AST rule node.
996
+ * @return {RuleWriteProfile} The rule's write profile.
997
+ */
998
+ function buildRuleWriteProfile (rule) {
999
+ const profile = {
1000
+ specificityByProperty: new Map(),
1001
+ weakestSpecificity: minimumRuleSpecificity(rule.selectors),
1002
+ strongestSpecificity: [0, 0, 0],
1003
+ writesStandardProperty: false
1004
+ };
1005
+ addWrittenProperties(profile, rule, maximumRuleSpecificity(rule.selectors));
1006
+ return profile;
1007
+ }
1008
+
1009
+ /**
1010
+ * Folds one rule's writes into another's profile, which is how a profile stays
1011
+ * current after a merge appends declarations to a rule. Both rules share the
1012
+ * same selectors, so the absorbed writes keep their recorded specificity.
1013
+ *
1014
+ * @param {RuleWriteProfile} profile The profile of the rule that grew.
1015
+ * @param {RuleWriteProfile} absorbedWrites The profile of the rule whose declarations were appended.
1016
+ */
1017
+ function absorbWriteProfile (profile, absorbedWrites) {
1018
+ for (const [property, specificity] of absorbedWrites.specificityByProperty) {
1019
+ addWrittenProperty(profile, property, specificity);
1020
+ }
1021
+ }
1022
+
1023
+ /**
1024
+ * Tracks which properties each rule writes to and where it sits, so that a
1025
+ * merge can ask whether anything after a position could override the
1026
+ * declarations it wants to move, instead of rescanning the stylesheet once per
1027
+ * merge.
1028
+ *
1029
+ * Entries for a property are appended in increasing position order, and each
1030
+ * new entry discards the earlier ones it dominates, since a declaration that is
1031
+ * both later and at least as specific blocks everything a weaker, earlier one
1032
+ * would. What remains is a list whose specificity strictly decreases as
1033
+ * position grows, so the first entry past a queried position is also the
1034
+ * strongest one past it.
1035
+ *
1036
+ * @return {object} An index exposing `recordRule` and `blocksRelocation`.
1037
+ */
1038
+ function createOverrideIndex () {
1039
+ const entriesByProperty = new Map();
1040
+ const profileByRule = new Map();
1041
+
1042
+ /**
1043
+ * Records that a property is written at a position with a given specificity.
1044
+ *
1045
+ * @param {string|symbol} property The leaf property name, or an index key.
1046
+ * @param {number} position The slot the writing rule occupies.
1047
+ * @param {Array} specificity The specificity the declaration applies with.
1048
+ */
1049
+ function addEntry (property, position, specificity) {
1050
+ let entries = entriesByProperty.get(property);
1051
+ if (!entries) {
1052
+ entries = [];
1053
+ entriesByProperty.set(property, entries);
1054
+ }
1055
+ while (entries.length && compareSpecificity(entries[entries.length - 1].specificity, specificity) <= 0) {
1056
+ entries.pop();
1057
+ }
1058
+ entries.push({ position, specificity });
1059
+ }
1060
+
1061
+ /**
1062
+ * Whether some rule after a position writes to a property with at least a
1063
+ * given specificity, and could therefore win the cascade against it.
1064
+ *
1065
+ * @param {string|symbol} property The leaf property name, or an index key.
1066
+ * @param {number} position The position to search after.
1067
+ * @param {Array} specificity The specificity to compare against.
1068
+ * @return {boolean} True when a later rule could override the property.
1069
+ */
1070
+ function hasStrongerEntryAfter (property, position, specificity) {
1071
+ const entries = entriesByProperty.get(property);
1072
+ if (!entries) {
1073
+ return false;
1074
+ }
1075
+ let low = 0;
1076
+ let high = entries.length;
1077
+ while (low < high) {
1078
+ const middle = Math.floor((low + high) / 2);
1079
+ if (entries[middle].position > position) {
1080
+ high = middle;
1081
+ } else {
1082
+ low = middle + 1;
1083
+ }
1084
+ }
1085
+ if (low === entries.length) {
1086
+ return false;
1087
+ }
1088
+ return compareSpecificity(entries[low].specificity, specificity) >= 0;
1089
+ }
1090
+
1091
+ /**
1092
+ * Returns a rule's write profile, building it the first time it is needed.
1093
+ * Rules only ever grow here, by absorbing another rule's declarations, so a
1094
+ * profile stays valid for as long as the merge pass runs.
1095
+ *
1096
+ * @param {object} rule The AST rule node.
1097
+ * @return {RuleWriteProfile} The rule's write profile.
1098
+ */
1099
+ function getWriteProfile (rule) {
1100
+ let profile = profileByRule.get(rule);
1101
+ if (!profile) {
1102
+ profile = buildRuleWriteProfile(rule);
1103
+ profileByRule.set(rule, profile);
1104
+ }
1105
+ return profile;
1106
+ }
1107
+
1108
+ /**
1109
+ * Records every property a rule writes to at the position it occupies.
1110
+ *
1111
+ * @param {object} rule The AST rule node.
1112
+ * @param {number} position The slot the rule occupies.
1113
+ */
1114
+ function recordRule (rule, position) {
1115
+ const profile = getWriteProfile(rule);
1116
+ if (!profile.specificityByProperty.size) {
1117
+ return;
1118
+ }
1119
+ for (const [property, specificity] of profile.specificityByProperty) {
1120
+ addEntry(property, position, specificity);
1121
+ }
1122
+ addEntry(ANY_PROPERTY, position, profile.strongestSpecificity);
1123
+ }
1124
+
1125
+ /**
1126
+ * Folds a rule's writes into the rule that just absorbed its declarations, so
1127
+ * the grown rule is treated as writing to both sets of properties.
1128
+ *
1129
+ * @param {object} rule The rule that grew.
1130
+ * @param {object} absorbedRule The rule whose declarations were appended to it.
1131
+ */
1132
+ function absorbRule (rule, absorbedRule) {
1133
+ absorbWriteProfile(getWriteProfile(rule), getWriteProfile(absorbedRule));
1134
+ }
1135
+
1136
+ /**
1137
+ * Whether moving a rule's declarations past everything recorded after a
1138
+ * position would change which declaration wins the cascade. Only a rule that
1139
+ * is strictly less specific is guaranteed to lose either way, so anything of
1140
+ * equal or greater specificity that writes to the same properties blocks the
1141
+ * move.
1142
+ *
1143
+ * @param {object} movingRule The rule whose declarations would relocate.
1144
+ * @param {number} afterPosition The position the declarations would move across.
1145
+ * @return {boolean} True when the relocation is unsafe.
1146
+ */
1147
+ function blocksRelocation (movingRule, afterPosition) {
1148
+ const profile = getWriteProfile(movingRule);
1149
+ const specificity = profile.weakestSpecificity;
1150
+ for (const property of profile.specificityByProperty.keys()) {
1151
+ if (hasStrongerEntryAfter(property, afterPosition, specificity)) {
1152
+ return true;
1153
+ }
1154
+ }
1155
+ if (profile.writesStandardProperty && hasStrongerEntryAfter(RESET_ALL_PROPERTY, afterPosition, specificity)) {
1156
+ return true;
1157
+ }
1158
+ return (
1159
+ profile.specificityByProperty.has(RESET_ALL_PROPERTY) &&
1160
+ hasStrongerEntryAfter(ANY_PROPERTY, afterPosition, specificity)
1161
+ );
1162
+ }
1163
+
1164
+ return {
1165
+ absorbRule,
1166
+ blocksRelocation,
1167
+ recordRule
1168
+ };
1169
+ }
1170
+
1171
+ /**
1172
+ * Builds the key two rules must share to be considered the same selector.
1173
+ *
1174
+ * @param {object} rule The AST rule node.
1175
+ * @return {string} The normalized, order independent selector key.
1176
+ */
1177
+ function buildSelectorKey (rule) {
1178
+ if (!rule.selectors) {
1179
+ return '';
1180
+ }
1181
+ return rule.selectors
1182
+ .map((selector) => {
1183
+ return normalizeSelector(selector);
1184
+ })
1185
+ .sort()
1186
+ .join(',');
1187
+ }
1188
+
1189
+ /**
1190
+ * Placement for a merged rule whose combined declarations belong where the
1191
+ * later of the two rules was, because the earlier declarations moved down.
1192
+ *
1193
+ * @type {string}
1194
+ */
1195
+ const MERGE_AT_LATER_RULE = 'later';
1196
+
1197
+ /**
1198
+ * Placement for a merged rule whose combined declarations belong where the
1199
+ * earlier of the two rules was, because the later declarations moved up.
1200
+ *
1201
+ * @type {string}
1202
+ */
1203
+ const MERGE_AT_EARLIER_RULE = 'earlier';
1204
+
1205
+ /**
1206
+ * Decides where two rules with the same selector can be combined. Merging them
1207
+ * always makes one set of declarations cross whatever separates the two rules,
1208
+ * which can flip a conflict the crossing declarations used to win or lose, so
1209
+ * the merged rule goes wherever the set that moved keeps its old standing.
1210
+ * Nothing separates adjacent rules, so those always merge.
1211
+ *
1212
+ * @param {object} overrideIndex The index of what each position writes to.
1213
+ * @param {object} earlierRule The first of the two rules with this selector.
1214
+ * @param {object} laterRule The second of the two rules with this selector.
1215
+ * @param {number} earlierPosition The slot the earlier rule occupies.
1216
+ * @param {number} laterPosition The slot the later rule would occupy.
1217
+ * @return {string|null} Where to place the merged rule, or null when merging is unsafe.
1218
+ */
1219
+ function chooseMergePlacement (overrideIndex, earlierRule, laterRule, earlierPosition, laterPosition) {
1220
+ const rulesAreAdjacent = earlierPosition === laterPosition - 1;
1221
+ if (rulesAreAdjacent || !overrideIndex.blocksRelocation(earlierRule, earlierPosition)) {
1222
+ return MERGE_AT_LATER_RULE;
1223
+ }
1224
+ if (!overrideIndex.blocksRelocation(laterRule, earlierPosition)) {
1225
+ return MERGE_AT_EARLIER_RULE;
1226
+ }
1227
+ return null;
1228
+ }
1229
+
1230
+ /**
1231
+ * Merges rules with identical normalized selectors by combining their declarations, as long as `chooseMergePlacement` finds a spot for the combined rule that the cascade reads the same way. Non-rule entries (like `@media`) break the merge window.
832
1232
  *
833
1233
  * @param {Array} rules The AST rule nodes to merge.
834
1234
  * @return {Array} A new array of rules with same-selector rules combined.
835
1235
  */
836
1236
  function mergeSelectorRules (rules) {
837
- // A merged rule moves to the end of the output. Its old slot is emptied
838
- // instead of spliced out so that every recorded position stays valid, and the
839
- // position map locates that slot without searching the output.
1237
+ // A rule merged at the later of the two positions moves to the end of the
1238
+ // output. Its old slot is emptied instead of spliced out so that every
1239
+ // recorded position stays valid, and the position map locates that slot
1240
+ // without searching the output.
840
1241
  const slots = [];
841
1242
  const positionByRule = new Map();
842
1243
  const selectorMap = new Map();
1244
+ const overrideIndex = createOverrideIndex();
843
1245
  for (const rule of rules) {
844
- if (rule.type === 'rule') {
845
- const selectorKey = rule.selectors ?
846
- rule.selectors.map((selector) => {
847
- return normalizeSelector(selector);
848
- }).sort().join(',') :
849
- '';
850
- if (selectorKey && selectorMap.has(selectorKey)) {
851
- const existingRule = selectorMap.get(selectorKey);
852
- existingRule.declarations.push(...(rule.declarations || []));
853
- slots[positionByRule.get(existingRule)] = null;
854
- positionByRule.set(existingRule, slots.length);
855
- slots.push(existingRule);
856
- } else {
857
- selectorMap.set(selectorKey, rule);
858
- positionByRule.set(rule, slots.length);
859
- slots.push(rule);
860
- }
861
- } else {
862
- if (rule.type === 'whitespace') {
863
- continue;
864
- }
1246
+ if (rule.type === 'whitespace') {
1247
+ continue;
1248
+ }
1249
+ if (rule.type !== 'rule') {
865
1250
  slots.push(rule);
866
1251
  selectorMap.clear();
867
1252
  positionByRule.clear();
1253
+ continue;
1254
+ }
1255
+ const selectorKey = buildSelectorKey(rule);
1256
+ const existingRule = selectorKey && selectorMap.get(selectorKey);
1257
+ if (existingRule) {
1258
+ const existingPosition = positionByRule.get(existingRule);
1259
+ const mergedPosition = slots.length;
1260
+ const placement = chooseMergePlacement(overrideIndex, existingRule, rule, existingPosition, mergedPosition);
1261
+ if (placement) {
1262
+ existingRule.declarations.push(...(rule.declarations || []));
1263
+ overrideIndex.absorbRule(existingRule, rule);
1264
+ if (placement === MERGE_AT_LATER_RULE) {
1265
+ slots[existingPosition] = null;
1266
+ positionByRule.set(existingRule, mergedPosition);
1267
+ slots.push(existingRule);
1268
+ overrideIndex.recordRule(existingRule, mergedPosition);
1269
+ } else {
1270
+ // The merged declarations now live at the earlier slot, but they are
1271
+ // recorded at the later one, since the index is only ever appended
1272
+ // to. Reading them as later than they are can only hold back a
1273
+ // further merge, never allow an unsafe one.
1274
+ overrideIndex.recordRule(rule, mergedPosition);
1275
+ }
1276
+ continue;
1277
+ }
868
1278
  }
1279
+ selectorMap.set(selectorKey, rule);
1280
+ positionByRule.set(rule, slots.length);
1281
+ overrideIndex.recordRule(rule, slots.length);
1282
+ slots.push(rule);
869
1283
  }
870
1284
  return slots.filter((slot) => {
871
1285
  return slot !== null;