|
| 1 | +# WP_HTML_Style_Attribute_Processor Specification |
| 2 | + |
| 3 | +## Purpose |
| 4 | + |
| 5 | +`WP_HTML_Style_Attribute_Processor` inspects and modifies decoded CSS text from an |
| 6 | +HTML `style` attribute. Its input is the declaration-list contents of the |
| 7 | +attribute, excluding HTML attribute syntax, entity decoding, and any surrounding |
| 8 | +declaration-block braces. |
| 9 | + |
| 10 | +The processor preserves the CSS declaration-list model. Duplicate declarations |
| 11 | +remain distinct. Invalid CSS fragments are skipped or preserved according to CSS |
| 12 | +parser semantics unless a requested mutation cannot be proven safe. |
| 13 | + |
| 14 | +The processor's north star is: |
| 15 | + |
| 16 | +1. preserve CSS structure; |
| 17 | +2. preserve user intent; |
| 18 | +3. preserve the semantic CSS value of any existing declaration it rewrites; |
| 19 | +4. make minimal edits when doing so does not compromise the first three goals. |
| 20 | + |
| 21 | +## Construction |
| 22 | + |
| 23 | +Creation uses: |
| 24 | + |
| 25 | +```php |
| 26 | +WP_HTML_Style_Attribute_Processor::create( string $decoded_css_text ): static |
| 27 | +``` |
| 28 | + |
| 29 | +The constructor is private. `create()` always returns a processor instance for a |
| 30 | +string input. |
| 31 | + |
| 32 | +Callers must not pass raw `WP_HTML_Tag_Processor::get_attribute( 'style' )` |
| 33 | +results directly unless they have already checked that the result is a string. |
| 34 | +Missing or boolean attributes are an HTML-layer concern, not accepted CSS text. |
| 35 | + |
| 36 | +Parsing is lazy. Creating a processor does not scan the style text until cursor |
| 37 | +movement, mutation validation, or serialization requires it. |
| 38 | + |
| 39 | +## Declaration Model |
| 40 | + |
| 41 | +The processor models each CSS declaration as: |
| 42 | + |
| 43 | +```text |
| 44 | +property-name: component-value-list !important? |
| 45 | +``` |
| 46 | + |
| 47 | +The internal declaration data consists of: |
| 48 | + |
| 49 | +- decoded property name; |
| 50 | +- authored property-name source when available; |
| 51 | +- value component list source range; |
| 52 | +- `!important` flag; |
| 53 | +- nullable source range for the parsed priority span; |
| 54 | +- source ranges needed for safe mutation and verification. |
| 55 | + |
| 56 | +`!important` is not part of the value, including for custom properties. |
| 57 | + |
| 58 | +## Public API |
| 59 | + |
| 60 | +Initial public methods: |
| 61 | + |
| 62 | +```php |
| 63 | +public static function create( string $decoded_css_text ): static; |
| 64 | +public function next_declaration( ?string $property_name = null ): bool; |
| 65 | +public function get_property_name(): ?string; |
| 66 | +public function is_important(): ?bool; |
| 67 | +public function set_value( string $value, ?bool $important = null ): bool; |
| 68 | +public function set_important( bool $important ): bool; |
| 69 | +public function remove_declaration(): bool; |
| 70 | +public function append_declaration( string $property_name, string $value, bool $important = false ): bool; |
| 71 | +public function get_updated_style(): string; |
| 72 | +``` |
| 73 | + |
| 74 | +The first API does not expose `get_value()` or `get_raw_value()`. Raw CSS values |
| 75 | +are difficult for callers to use safely, and a higher-level value API needs a |
| 76 | +separate design. |
| 77 | + |
| 78 | +Bookmarks, seek, and rewind are out of scope. Callers that need to rescan should |
| 79 | +create a new processor from the updated style text. |
| 80 | + |
| 81 | +## Cursor Semantics |
| 82 | + |
| 83 | +`next_declaration()` advances a forward-only cursor over CSS declarations. |
| 84 | + |
| 85 | +All current-declaration getters return `null` when the cursor is not positioned |
| 86 | +on a valid declaration. This includes `is_important()`. |
| 87 | + |
| 88 | +After `remove_declaration()` succeeds, the cursor becomes invalid until |
| 89 | +`next_declaration()` advances it. A second removal on the same invalid cursor |
| 90 | +returns `false`. |
| 91 | + |
| 92 | +After `set_value()` or `set_important()` succeeds without removing the |
| 93 | +declaration, the cursor remains on the same logical declaration. |
| 94 | + |
| 95 | +`set_value( '' )` removes the current declaration. On success it has the same |
| 96 | +cursor semantics as `remove_declaration()`. |
| 97 | + |
| 98 | +`append_declaration()` does not move the cursor. If the cursor was exhausted, it |
| 99 | +remains invalid until a later `next_declaration()` advances to the appended |
| 100 | +declaration. |
| 101 | + |
| 102 | +`next_declaration()` flushes pending safe edits before advancing because |
| 103 | +advancement must reflect the updated declaration list. |
| 104 | + |
| 105 | +Current-position getters should reflect successful pending edits without forcing |
| 106 | +a full reparse where practical, following HTML API style. |
| 107 | + |
| 108 | +## Property Names |
| 109 | + |
| 110 | +All public property-name arguments are decoded/plaintext CSS property names, not |
| 111 | +CSS-escaped identifier source. |
| 112 | + |
| 113 | +Ordinary properties match ASCII case-insensitively. Custom properties match |
| 114 | +exactly because casing is semantic. |
| 115 | + |
| 116 | +`get_property_name()` exposes CSS semantics: |
| 117 | + |
| 118 | +- ordinary property names are returned lowercase; |
| 119 | +- custom property names preserve exact decoded casing. |
| 120 | + |
| 121 | +Edits preserve authored property-name spelling when the declaration already |
| 122 | +exists. Appended ordinary properties serialize lowercase because there is no |
| 123 | +authored spelling to preserve. Appended custom properties preserve exact casing. |
| 124 | + |
| 125 | +Appended property names must be valid plaintext CSS property names. The processor |
| 126 | +does not escape arbitrary strings into identifiers. Custom properties follow the |
| 127 | +same validity rule with the required `--` prefix. |
| 128 | + |
| 129 | +## Value Validation |
| 130 | + |
| 131 | +Mutation inputs accept CSS declaration values, not declaration-list text. |
| 132 | + |
| 133 | +The supplied value must fit safely in both of these declaration slots, depending |
| 134 | +on the property being changed: |
| 135 | + |
| 136 | +```css |
| 137 | +foo: <value>; |
| 138 | +--foo: <value>; |
| 139 | +``` |
| 140 | + |
| 141 | +Validation checks structure and CSS syntax safety, not property-specific browser |
| 142 | +support. The processor should not maintain a browser-style matrix of supported |
| 143 | +properties and value grammars. |
| 144 | + |
| 145 | +Values must be complete and self-contained. Reject inputs that: |
| 146 | + |
| 147 | +- are empty after CSS whitespace trimming, except `set_value( '' )`, which |
| 148 | + removes the current declaration; |
| 149 | +- contain top-level declaration separators such as semicolons; |
| 150 | +- contain a top-level `!important`, because priority is a separate argument; |
| 151 | +- contain bad strings or bad URLs; |
| 152 | +- contain unmatched delimiters or constructs that are valid only because CSS |
| 153 | + closes them at EOF; |
| 154 | +- cannot be proven to occupy only the declaration value slot. |
| 155 | + |
| 156 | +Nested semicolons and `!important` text inside balanced blocks, functions, |
| 157 | +strings, or URLs are allowed when the tokenizer and parser say they are part of |
| 158 | +the value. |
| 159 | + |
| 160 | +## Importance |
| 161 | + |
| 162 | +`set_value( $value, null )` preserves the existing important flag. |
| 163 | +`set_value( $value, true )` sets it. |
| 164 | +`set_value( $value, false )` clears it. |
| 165 | + |
| 166 | +`append_declaration()` receives importance as a separate boolean and rejects a |
| 167 | +top-level `!important` in `$value`. |
| 168 | + |
| 169 | +`set_important()` changes only declaration priority. |
| 170 | + |
| 171 | +When clearing importance, the processor may remove only the parsed priority span |
| 172 | +when that is safe. If minimal removal is unsafe, it may normalize the current |
| 173 | +declaration as long as it preserves CSS structure and the existing value's |
| 174 | +semantics. If that cannot be proven, it returns `false`. |
| 175 | + |
| 176 | +When setting importance, the processor adds canonical ` !important` at the |
| 177 | +parsed value end or normalizes the declaration if necessary. It must verify that |
| 178 | +the updated declaration reparses as important. |
| 179 | + |
| 180 | +## Mutation Atomicity And Verification |
| 181 | + |
| 182 | +Invalid or inapplicable mutations return `false` without `_doing_it_wrong()`. |
| 183 | + |
| 184 | +Failed mutations are atomic: style text, cursor state, and previous successful |
| 185 | +edits are preserved. |
| 186 | + |
| 187 | +A mutation returning `true` guarantees that `get_updated_style()` includes it. |
| 188 | + |
| 189 | +Every successful mutation must preserve the expected declaration-list structure. |
| 190 | +`set_value()` must not let an input value merge, split, hide, or otherwise change |
| 191 | +declarations beyond the intended current declaration. |
| 192 | + |
| 193 | +The public contract describes guarantees, not the exact validation mechanism. |
| 194 | +Synthetic declarations, parser comparisons, token-level checks, and reparsing are |
| 195 | +implementation details. |
| 196 | + |
| 197 | +Multiple mutations to the same logical declaration collapse to the final |
| 198 | +intended state. Mutations to different declarations in one pass are allowed when |
| 199 | +each mutation can be independently verified against the updated declaration |
| 200 | +list. |
| 201 | + |
| 202 | +## Formatting And Normalization |
| 203 | + |
| 204 | +The processor preserves surrounding text, comments, ignored invalid fragments, |
| 205 | +and authored spelling when cheap and safe. |
| 206 | + |
| 207 | +Minimal edits are a lower priority than safety, correctness, preserved CSS |
| 208 | +structure, and semantic preservation. Any normalization required to satisfy |
| 209 | +those higher priorities is allowed, but normalization that rewrites an existing |
| 210 | +declaration value must be strict: it must serialize from parsed token/component |
| 211 | +data with equivalent CSS semantics. If equivalence cannot be proven, the |
| 212 | +mutation returns `false`. |
| 213 | + |
| 214 | +`set_value()` trims supplied values according to CSS whitespace. |
| 215 | + |
| 216 | +When possible, `set_value()` replaces the current declaration's value and |
| 217 | +priority portion rather than normalizing the whole declaration. Correctness wins |
| 218 | +over whitespace preservation. |
| 219 | + |
| 220 | +## Invalid Fragments |
| 221 | + |
| 222 | +Declaration discovery follows CSS semantics. If CSS would ignore text as a |
| 223 | +declaration in a style attribute declaration-list context, the processor does |
| 224 | +not expose it as a declaration. |
| 225 | + |
| 226 | +Ignored invalid fragments are preserved where possible. Mutations touching a |
| 227 | +style containing invalid chunks must verify that the declaration-list structure |
| 228 | +remains safe. If preservation and a requested mutation are incompatible, the |
| 229 | +mutation returns `false` or normalizes only the range necessary to preserve |
| 230 | +semantics and structure. |
| 231 | + |
| 232 | +## Append And EOF Repair |
| 233 | + |
| 234 | +`append_declaration()` may succeed even when trailing text is malformed, but only |
| 235 | +when the processor can preserve existing CSS semantics. |
| 236 | + |
| 237 | +When trailing declaration text is valid only because CSS closes an unclosed |
| 238 | +function or block at EOF, appending must materialize the missing closing |
| 239 | +delimiters before inserting the new declaration. |
| 240 | + |
| 241 | +Example: |
| 242 | + |
| 243 | +```css |
| 244 | +color: var(--x |
| 245 | +``` |
| 246 | + |
| 247 | +Appending `background: white` should produce a structurally safe equivalent such |
| 248 | +as: |
| 249 | + |
| 250 | +```css |
| 251 | +color: var(--x); background: white; |
| 252 | +``` |
| 253 | + |
| 254 | +EOF repair is part of a successful append and appears in `get_updated_style()`. |
| 255 | +Repair should insert only the specific missing delimiters discovered from parser |
| 256 | +or tokenizer state, plus the boundary needed before the appended declaration. If |
| 257 | +the processor cannot determine a precise repair, append returns `false`. |
| 258 | + |
| 259 | +EOF repair is essential for append. `set_value()` replacement values must be |
| 260 | +self-contained and do not receive EOF repair. |
| 261 | + |
| 262 | +## Test Requirements |
| 263 | + |
| 264 | +Tests should cover: |
| 265 | + |
| 266 | +- private construction and `create()` behavior; |
| 267 | +- decoded string input boundary; |
| 268 | +- declaration traversal, duplicate preservation, and property-name matching; |
| 269 | +- getter `null` behavior off-cursor; |
| 270 | +- absence of public raw value getter in the first API; |
| 271 | +- `set_value()` priority preservation, setting, clearing, and empty-value |
| 272 | + removal; |
| 273 | +- `set_important()` setting, clearing, cursor behavior, and safe normalization; |
| 274 | +- rejection of unsafe values and property names with atomic failure; |
| 275 | +- append behavior, duplicate appends, exhausted cursor behavior, and empty-value |
| 276 | + rejection; |
| 277 | +- EOF repair for append and failure when repair cannot be precise; |
| 278 | +- invalid fragments, comments, adjacent declarations, and removal ranges; |
| 279 | +- preservation of authored spelling where required; |
| 280 | +- integration with `WP_HTML_Tag_Processor::set_attribute()` after callers supply |
| 281 | + decoded string input. |
0 commit comments