Library to rectify mistakes in the design of CSS. npmx.dev/package/@declanchidlow/fixcss
webdev front-end css fixcss
8

Configure Feed

Select the types of activity you want to include in your feed.

Push first version

Declan Chidlow (Jul 1, 2026, 11:18 AM +0800) 9e620414

+1176
+123
README.md
··· 1 + <div align="center"> 2 + <h1> 3 + FixCSS 4 + </h1> 5 + CSS as it *should* have been designed. 6 + </div> 7 + <br/> 8 + 9 + In the design of CSS, mistakes were made. In fact, quite a number. The CSS Working Group says as much, having published an [Incomplete List of Mistakes in the Design of CSS](https://wiki.csswg.org/ideas/mistakes/). 10 + 11 + This library rectifies these failings, and allows writing CSS the way it _should_ have been designed. FixCSS automatically converts corrected property names, values, selectors, and syntax, as an illustration of what should have been. It also fixes newer syntax compromised by trying to maintain consistency with previous mistakes. 12 + 13 + ## Usage 14 + 15 + ### Client-side 16 + 17 + To use this script, simply include it in your HTML: 18 + 19 + ```html 20 + <script src="fixcss.js"></script> 21 + ``` 22 + 23 + That's it. All `<style>` blocks, inline styles, and future DOM mutations are auto-converted. No build step needed. 24 + 25 + ### Build-time 26 + 27 + ```bash 28 + # CLI 29 + node fixcss-cli.js styles.css -o styles.fixed.css 30 + 31 + # Or programmatically 32 + npm install fixcss 33 + ``` 34 + 35 + ```js 36 + const { fixCSS } = require("fixcss"); 37 + const fs = require("fs"); 38 + 39 + const input = fs.readFileSync("styles.css", "utf8"); 40 + fs.writeFileSync("styles.out.css", fixCSS(input)); 41 + ``` 42 + 43 + ## API Reference 44 + 45 + ### Core Functions 46 + 47 + #### `fixCSS.fixCSS(css, options?)` 48 + 49 + Convert a CSS string from fixed syntax to browser-compatible CSS. 50 + 51 + ```js 52 + const out = fixCSS.fixCSS(".box { corner-radius: 10px; }"); 53 + // → '.box { border-radius: 10px; }' 54 + ``` 55 + 56 + **Options:** 57 + 58 + - `fixShorthandDefaults` (default: `true`) — Single-value `background-size` duplicates to both axes, single-value `translate()` duplicates 59 + - `fixAxisOrder` (default: `true`) — Swap vertical-first axis order to horizontal-first for `background-position` and `border-spacing` 60 + - `fixSelectorCombinators` (default: `true`) — Convert `>>` → ` ` and `++` → `~` 61 + 62 + #### `fixCSS.convertCCW(css)` 63 + 64 + Convert CCW-ordered 4-value shorthands to CW (see mistake #11). This fix is opt-in, due to just being numbers and there unable to be auto-detected. 65 + 66 + ```js 67 + // CCW: top, left, bottom, right → CW: top, right, bottom, left 68 + fixCSS.convertCCW(".x { margin: 10px 20px 30px 40px; }"); 69 + // → '.x { margin: 10px 40px 30px 20px; }' 70 + ``` 71 + 72 + #### `fixCSS.convertProperties(css)` 73 + 74 + Convert only property names (not values or selectors). 75 + 76 + ### Browser Runtime 77 + 78 + #### `fixCSS.start()` 79 + 80 + Start observing the DOM for styles and auto-convert. Called automatically on load. 81 + 82 + #### `fixCSS.shutdown()` 83 + 84 + Stop observing the DOM. 85 + 86 + #### `fixCSS.convertPage()` 87 + 88 + Manually convert all `<style>` blocks and inline styles on the page. 89 + 90 + #### `fixCSS.debug(flag)` 91 + 92 + Enable/disable debug logging to the console. 93 + 94 + ## CLI Usage 95 + 96 + ```bash 97 + # Convert a file 98 + node fixcss-cli.js input.css -o output.css 99 + 100 + # Read from stdin 101 + cat input.css | node fixcss-cli.js --stdin > output.css 102 + 103 + # Watch mode 104 + node fixcss-cli.js input.css -o output.css --watch 105 + 106 + # With options 107 + node fixcss-cli.js input.css -o output.css --ccw --no-axis 108 + ``` 109 + 110 + | Flag | Description | 111 + | --------------------- | ---------------------------------- | 112 + | `-o, --output <file>` | Write output to file | 113 + | `-w, --watch` | Watch input file for changes | 114 + | `--stdin` | Read from stdin | 115 + | `--no-shorthand` | Don't fix shorthand defaults | 116 + | `--no-axis` | Don't fix axis order | 117 + | `--no-combinators` | Don't fix selector combinators | 118 + | `--ccw` | Enable CCW→CW shorthand conversion | 119 + | `-h, --help` | Show help | 120 + 121 + ## See Also 122 + 123 + - [BritCSS](https://github.com/DeclanChidlow/BritCSS) (the catalyst for this project)
+161
fixcss-cli.js
··· 1 + #!/usr/bin/env node 2 + 3 + var fs = require("fs"); 4 + var path = require("path"); 5 + var { fixCSS, convertCCW } = require("./fixcss.js"); 6 + 7 + var args = process.argv.slice(2); 8 + 9 + var opts = { 10 + fixShorthandDefaults: true, 11 + fixAxisOrder: true, 12 + fixSelectorCombinators: true, 13 + ccwShorthand: false, 14 + outputFile: null, 15 + inputFile: null, 16 + watch: false, 17 + stdin: false, 18 + }; 19 + 20 + for (var i = 0; i < args.length; i++) { 21 + var arg = args[i]; 22 + switch (arg) { 23 + case "-o": 24 + case "--output": 25 + opts.outputFile = args[++i]; 26 + break; 27 + case "-w": 28 + case "--watch": 29 + opts.watch = true; 30 + break; 31 + case "--no-shorthand": 32 + opts.fixShorthandDefaults = false; 33 + break; 34 + case "--no-axis": 35 + opts.fixAxisOrder = false; 36 + break; 37 + case "--no-combinators": 38 + opts.fixSelectorCombinators = false; 39 + break; 40 + case "--ccw": 41 + opts.ccwShorthand = true; 42 + break; 43 + case "--stdin": 44 + opts.stdin = true; 45 + break; 46 + case "-h": 47 + case "--help": 48 + console.log( 49 + [ 50 + "FixCSS CLI — Fixes CSS mistakes", 51 + "", 52 + "Usage:", 53 + " fixcss input.css > output.css", 54 + " fixcss input.css -o output.css", 55 + " fixcss --stdin < input.css", 56 + " cat input.css | fixcss --stdin", 57 + "", 58 + "Options:", 59 + " -o, --output <file> Write output to file", 60 + " -w, --watch Watch file for changes", 61 + " --no-shorthand Don't fix shorthand defaults", 62 + " --no-axis Don't fix axis order (V→H swap)", 63 + " --no-combinators Don't fix selector combinators", 64 + " --ccw Enable CCW→CW shorthand conversion", 65 + " --stdin Read from stdin", 66 + " -h, --help Show this help", 67 + ].join("\n"), 68 + ); 69 + process.exit(0); 70 + break; 71 + default: 72 + if (arg[0] !== "-") { 73 + opts.inputFile = arg; 74 + } 75 + break; 76 + } 77 + } 78 + 79 + function processCSS(input, sourcePath) { 80 + try { 81 + var converter = opts.ccwShorthand ? convertCCW : fixCSS; 82 + var output = converter(input, { 83 + fixShorthandDefaults: opts.fixShorthandDefaults, 84 + fixAxisOrder: opts.fixAxisOrder, 85 + fixSelectorCombinators: opts.fixSelectorCombinators, 86 + ccwShorthand: opts.ccwShorthand, 87 + }); 88 + 89 + if (opts.outputFile) { 90 + fs.writeFileSync(opts.outputFile, output, "utf8"); 91 + if (!opts.watch) { 92 + console.error("✓ Converted: " + (sourcePath || "stdin") + " → " + opts.outputFile); 93 + } 94 + } else { 95 + process.stdout.write(output); 96 + } 97 + } catch (e) { 98 + console.error("Error converting CSS:", e.message); 99 + process.exit(1); 100 + } 101 + } 102 + 103 + // ─── Main ──────────────────────────────────────────────────────────────── 104 + 105 + if (opts.stdin) { 106 + var chunks = []; 107 + process.stdin.setEncoding("utf8"); 108 + process.stdin.on("data", function (chunk) { 109 + chunks.push(chunk); 110 + }); 111 + process.stdin.on("end", function () { 112 + processCSS(chunks.join(""), "stdin"); 113 + }); 114 + } else if (opts.inputFile) { 115 + var inputPath = path.resolve(opts.inputFile); 116 + if (!fs.existsSync(inputPath)) { 117 + console.error("Error: File not found: " + inputPath); 118 + process.exit(1); 119 + } 120 + 121 + if (opts.watch) { 122 + console.error("Watching " + inputPath + " for changes…"); 123 + function doConvert() { 124 + try { 125 + var input = fs.readFileSync(inputPath, "utf8"); 126 + var output = fixCSS(input, { 127 + fixShorthandDefaults: opts.fixShorthandDefaults, 128 + fixAxisOrder: opts.fixAxisOrder, 129 + fixSelectorCombinators: opts.fixSelectorCombinators, 130 + ccwShorthand: opts.ccwShorthand, 131 + }); 132 + if (opts.outputFile) { 133 + fs.writeFileSync(opts.outputFile, output, "utf8"); 134 + console.error("✓ Converted at " + new Date().toLocaleTimeString()); 135 + } else { 136 + process.stdout.write(output); 137 + } 138 + } catch (e) { 139 + console.error("Error:", e.message); 140 + } 141 + } 142 + 143 + doConvert(); 144 + fs.watch(inputPath, { persistent: true }, function (event) { 145 + if (event === "change") { 146 + // Small debounce 147 + clearTimeout(doConvert._timeout); 148 + doConvert._timeout = setTimeout(doConvert, 100); 149 + } 150 + }); 151 + 152 + process.stdin.resume(); 153 + } else { 154 + var input = fs.readFileSync(inputPath, "utf8"); 155 + processCSS(input, inputPath); 156 + } 157 + } else { 158 + console.error("Error: No input file specified. Use --stdin or provide a filename."); 159 + console.error("Run with --help for usage."); 160 + process.exit(1); 161 + }
+525
fixcss.js
··· 1 + /** 2 + * FixCSS - Rectifies mistakes in the design of CSS 3 + * Developed by Declan Chidlow (https://vale.rocks) 4 + * @version 1.0.0 5 + */ 6 + 7 + (function (root, factory) { 8 + if (typeof define === "function" && define.amd) { 9 + define([], factory); 10 + } else if (typeof module === "object" && module.exports) { 11 + module.exports = factory(); 12 + } else { 13 + root.fixCSS = factory(); 14 + } 15 + })(typeof self !== "undefined" ? self : this, function () { 16 + "use strict"; 17 + 18 + // ─── Property Name Fixes ─────────────────────────────────────────────── 19 + var PROPERTY_FIXES = { 20 + // #2 — animation-iteration-count is unnecessarily verbose 21 + "animation-count": "animation-iteration-count", 22 + 23 + // #12 — z-index should be z-order or depth 24 + "z-order": "z-index", 25 + "depth": "z-index", 26 + 27 + // #19 — border-radius rounds corners, not borders 28 + "corner-radius": "border-radius", 29 + 30 + // #21 — hyphens should be a verb (hyphenate), not a plural noun 31 + "hyphenate": "hyphens", 32 + 33 + // #24 — *-blend-mode is verbose; 'blend' suffices 34 + "mix-blend": "mix-blend-mode", 35 + "background-blend": "background-blend-mode", 36 + 37 + // #36 — display is too generic 38 + "display-type": "display", 39 + 40 + // #37 — list-style references HTML concepts, not presentation 41 + "marker-style": "list-style", 42 + "marker-style-type": "list-style-type", 43 + "marker-style-position": "list-style-position", 44 + "marker-style-image": "list-style-image", 45 + 46 + // #46 — shape-outside should mention 'wrap' since it doesn't clip 47 + "wrap-shape": "shape-outside", 48 + "shape-wrap": "shape-outside", 49 + 50 + // #13 — overflow-wrap should be a white-space keyword (handled in values) but we also alias the longhand for completeness 51 + "wrap-words": "overflow-wrap", 52 + 53 + // Flexbox alignment: writing-mode relative names (mistake #45) 54 + "align-inline": "justify-content", 55 + "align-block": "align-items", 56 + "justify-inline": "justify-items", 57 + "justify-block": "justify-content", 58 + }; 59 + 60 + // ─── Value / Keyword Fixes ───────────────────────────────────────────── 61 + var VALUE_FIXES = { 62 + // #1 — white-space: no-wrap (hyphenated) 63 + "white-space": { 64 + "no-wrap": "nowrap", 65 + "wrap": "pre-wrap", // wrap should exist as a white-space value 66 + }, 67 + 68 + // #4 — vertical-align: text-middle (more accurate than 'middle') 69 + "vertical-align": { 70 + "text-middle": "middle", 71 + "x-middle": "middle", 72 + }, 73 + 74 + // #17 — system color keywords should be hyphenated 75 + "*": { 76 + "accent-color-text": "accentColorText", 77 + "accent-color": "accentColor", 78 + "current-color": "currentColor", 79 + }, 80 + }; 81 + 82 + // ─── Selector Fixes ──────────────────────────────────────────────────── 83 + var SELECTOR_FIXES = { 84 + // #29 — :void = current :empty (no children at all) 85 + // :empty should match whitespace-only elements (now :blank in spec) 86 + ":void": ":empty", 87 + "::placeholder-text": "::placeholder", // #40 88 + ":placeholder": ":placeholder-shown", // #40 89 + ":marked-block": "::marker", // #37 — list-item -> marked-block naming 90 + }; 91 + 92 + // ─── Display Value Fixes ─────────────────────────────────────────────── 93 + // See mistake #37 — list-item should be marked-block 94 + var DISPLAY_VALUES = { 95 + "marked-block": "list-item", 96 + }; 97 + 98 + // ─── Unicode Range Fixes ─────────────────────────────────────────────── 99 + // See mistake #25 — CSS-consistent unicode notation 100 + // Converts u+XXXX-XXXX -> U+XXXX-XXXX and u+XXXX -> U+XXXX 101 + var UNICODE_RANGE_RE = /u\+([0-9a-fA-F]+)/g; 102 + 103 + // ─── Core Conversion ─────────────────────────────────────────────────── 104 + 105 + /** 106 + * Convert a CSS string from 'fixed' syntax to browser-compatible CSS. 107 + * 108 + * @param {string} css - The CSS source to convert 109 + * @param {Object} [opts] - Options 110 + * @param {boolean} [opts.fixShorthandDefaults=true] - Apply behavioural fixes (e.g. single-value background-size duplication) 111 + * @param {boolean} [opts.fixAxisOrder=true] - Swap vertical-first axis order to horizontal-first 112 + * @param {boolean} [opts.fixSelectorCombinators=true] - Convert >> and ++ combinators 113 + * @returns {string} Converted CSS 114 + */ 115 + function fixCSS(css, opts) { 116 + opts = Object.assign( 117 + { 118 + fixShorthandDefaults: true, 119 + fixAxisOrder: true, 120 + fixSelectorCombinators: true, 121 + }, 122 + opts, 123 + ); 124 + 125 + var result = css; 126 + 127 + // Handle strings 128 + var strings = []; 129 + result = result.replace(/([''"])(?:(?!\1|\\).|\\.)*\1/g, function (match) { 130 + strings.push(match); 131 + return "STR" + (strings.length - 1) + ""; 132 + }); 133 + 134 + // Handle comments 135 + var comments = []; 136 + result = result.replace(/\/\*[\s\S]*?\*\//g, function (match) { 137 + comments.push(match); 138 + return "CMT" + (comments.length - 1) + ""; 139 + }); 140 + 141 + // Fix unicode ranges (#25) 142 + result = result.replace(UNICODE_RANGE_RE, function (match, hex) { 143 + return "U+" + hex.toUpperCase(); 144 + }); 145 + 146 + // Fix selectors 147 + // Match selectors preceded by {, }, ; or at the start of the string. 148 + // Capture separator whitespace in group 1 to preserve formatting. 149 + result = result.replace(/(^|[{};]\s*)([^{};]+?)(\s*\{)/gm, function (match, before, selectorBlock, brace) { 150 + var fixed = selectorBlock; 151 + 152 + // Fix combinator patterns (#23) — must be done before general selector fixes 153 + if (opts.fixSelectorCombinators) { 154 + // Descendant combinator: >> -> space 155 + // Only outside of functional notation 156 + fixed = fixed.replace(/\)\s*>>\s*/g, ") >> "); // mark >> after ) for later 157 + fixed = fixed.replace(/\s*>>\s*/g, " "); 158 + // Indirect sibling: ++ -> ~ 159 + fixed = fixed.replace(/\+\+/g, "~"); 160 + } 161 + 162 + // Fix pseudo-class/element names 163 + // Sort keys by length descending so longer matches (::placeholder-text) are processed before shorter ones (:placeholder) to avoid double-conversion 164 + var selectorKeys = Object.keys(SELECTOR_FIXES) 165 + .filter(function (k) { 166 + return SELECTOR_FIXES[k]; 167 + }) 168 + .sort(function (a, b) { 169 + return b.length - a.length; 170 + }); 171 + 172 + for (var i = 0; i < selectorKeys.length; i++) { 173 + var key = selectorKeys[i]; 174 + // Escape special regex chars in key 175 + var escapedKey = key.replace(/[.*+?^${}()|[\]\\:]/g, "\\$&"); 176 + // Use negative lookbehind so :placeholder doesn't match inside ::placeholder 177 + var regex = new RegExp("(?<!:)(" + escapedKey + ")(?=[\\s:,.[\\]#+>~()]|$)", "g"); 178 + fixed = fixed.replace(regex, SELECTOR_FIXES[key]); 179 + } 180 + 181 + return before + fixed + brace; 182 + }); 183 + 184 + // Phase 5: Fix property names and values inside declaration blocks 185 + result = result.replace(/\{([^}]*)\}/g, function (match, declarations) { 186 + var fixed = declarations; 187 + 188 + // 5a: Fix property names 189 + for (var prop in PROPERTY_FIXES) { 190 + var escapedProp = prop.replace(/([.*+?^${}()|[\]\\])/g, "\\$1"); 191 + var propRegex = new RegExp("(^|[;\\s\\n])(" + escapedProp + ")\\s*:", "g"); 192 + fixed = fixed.replace(propRegex, function (m, before) { 193 + return before + PROPERTY_FIXES[prop] + ":"; 194 + }); 195 + } 196 + 197 + // 5b: Fix display values (#37 — marked-block -> list-item, now that display-type -> display) 198 + fixed = fixed.replace(/display\s*:\s*([^;!}]+)/g, function (m, val) { 199 + var trimmed = val.trim(); 200 + if (DISPLAY_VALUES[trimmed]) { 201 + return "display: " + DISPLAY_VALUES[trimmed]; 202 + } 203 + return m; 204 + }); 205 + 206 + // 5c: Fix property-specific and global values (data-driven from VALUE_FIXES) 207 + fixed = fixPropertyValues(fixed); 208 + 209 + // 5d: Fix 4-arg rgb/hsl -> rgba/hsla (#22) 210 + fixed = fixed.replace(/\brgb\s*\(\s*([^,)]+)\s*,\s*([^,)]+)\s*,\s*([^,)]+)\s*,\s*([^,)]+)\s*\)/g, "rgba($1, $2, $3, $4)"); 211 + fixed = fixed.replace(/\bhsl\s*\(\s*([^,)]+)\s*,\s*([^,)]+)\s*,\s*([^,)]+)\s*,\s*([^,)]+)\s*\)/g, "hsla($1, $2, $3, $4)"); 212 + 213 + // 5e: Fix span(N) -> span N in grid properties (#43) 214 + fixed = fixed.replace(/\bspan\s*\(\s*(\d+)\s*\)/g, "span $1"); 215 + 216 + // 5f: Fix fr units in flex shorthand (#35) 217 + fixed = fixed.replace(/flex\s*:\s*([^;!}]+)/g, function (m, flexVals) { 218 + return "flex: " + flexVals.replace(/(\d+(?:\.\d+)?)fr/g, "$1"); 219 + }); 220 + 221 + // 5g: Fix shorthand defaults (#8) 222 + if (opts.fixShorthandDefaults) { 223 + fixed = fixShorthandValues(fixed); 224 + } 225 + 226 + // 5h: Fix axis order for 2-value properties (#9) 227 + if (opts.fixAxisOrder) { 228 + fixed = fixAxisOrder(fixed); 229 + } 230 + 231 + // 5i: Fix CCW -> CW for 4-value shorthands (#11, opt-in) 232 + fixed = fixShorthandDirection(fixed, opts); 233 + 234 + return "{" + fixed + "}"; 235 + }); 236 + 237 + // Phase 6: Restore comments 238 + result = result.replace(/CMT(\d+)/g, function (match, index) { 239 + return comments[parseInt(index, 10)]; 240 + }); 241 + 242 + // Phase 7: Restore strings 243 + result = result.replace(/STR(\d+)/g, function (match, index) { 244 + return strings[parseInt(index, 10)]; 245 + }); 246 + 247 + return result; 248 + } 249 + 250 + /** 251 + * Fix property-specific and global values, data-driven from VALUE_FIXES. 252 + * @private 253 + */ 254 + function fixPropertyValues(declarations) { 255 + var result = declarations; 256 + var globalFixes = VALUE_FIXES["*"] || {}; 257 + 258 + // Per-property value fixes 259 + for (var prop in VALUE_FIXES) { 260 + if (prop === "*") continue; 261 + var fixes = VALUE_FIXES[prop]; 262 + var escapedProp = prop.replace(/([.*+?^${}()|[\]\\])/g, "\\$1"); 263 + var propRegex = new RegExp(escapedProp + "\\s*:\\s*([^;!}]+)", "g"); 264 + result = result.replace(propRegex, function (m, val) { 265 + var trimmed = val.trim(); 266 + if (fixes[trimmed]) { 267 + return prop + ": " + fixes[trimmed]; 268 + } 269 + return m; 270 + }); 271 + } 272 + 273 + // Global value fixes (any property) — current-color -> currentColor, etc. 274 + // Process longest keys first so accent-color-text matches before accent-color 275 + var globalKeys = Object.keys(globalFixes).sort(function (a, b) { 276 + return b.length - a.length; 277 + }); 278 + for (var i = 0; i < globalKeys.length; i++) { 279 + var key = globalKeys[i]; 280 + var escapedKey = key.replace(/([.*+?^${}()|[\]\\])/g, "\\$1"); 281 + result = result.replace(new RegExp("\\b" + escapedKey + "\\b", "g"), globalFixes[key]); 282 + } 283 + 284 + return result; 285 + } 286 + 287 + /** 288 + * Fix shorthand defaults (#8). 289 + * Single-value background-size and translate() duplicate to both axes. 290 + * @private 291 + */ 292 + function fixShorthandValues(declarations) { 293 + return ( 294 + declarations 295 + // background-size: X -> background-size: X X (single value duplicates) 296 + .replace(/background-size\s*:\s*([^\s;!}]+)(?=\s*[;}!]|$)/g, function (m, val) { 297 + return /^(cover|contain|auto)$/.test(val) ? m : "background-size: " + val + " " + val; 298 + }) 299 + // translate(X) -> translate(X, X) 300 + .replace(/translate\s*\(\s*([^,)]+)\s*\)/g, function (m, val) { 301 + val = val.trim(); 302 + return val === "0" || val === "none" ? m : "translate(" + val + ", " + val + ")"; 303 + }) 304 + ); 305 + } 306 + 307 + /** 308 + * Fix axis order for properties that should be vertical-first (#9). 309 + * background-position and border-spacing currently take H first, V second. 310 + * We swap them so the 'correct' CSS (V first) works. 311 + * @private 312 + */ 313 + function fixAxisOrder(declarations) { 314 + return declarations.replace(/(background-position|border-spacing)\s*:\s*([^\s;!}]+)\s+([^\s;!}]+)(?=\s*[;}!]|$)/g, "$1: $3 $2"); 315 + } 316 + 317 + /** 318 + * Fix 4-value shorthand direction from CCW to CW (#11). 319 + * In 'correct' CSS, margin/padding 4-value shorthands go CCW: top, left, bottom, right. 320 + * Browsers use CW: top, right, bottom, left. 321 + * Opt-in via convertCCW() or { ccwShorthand: true }. 322 + * @private 323 + */ 324 + function fixShorthandDirection(declarations, opts) { 325 + if (!opts.ccwShorthand) return declarations; 326 + 327 + var props = "(margin|padding|border-color|border-style|border-width|inset|scroll-margin|scroll-padding)"; 328 + var regex = new RegExp(props + "\\s*:\\s*([^\\s;!}]+)\\s+([^\\s;!}]+)\\s+([^\\s;!}]+)\\s+([^\\s;!}]+)(?=\\s*[;}!]|$)", "g"); 329 + 330 + return declarations.replace(regex, function (match, prop, top, left, bottom, right) { 331 + // CCW: top, left, bottom, right -> CW: top, right, bottom, left 332 + return prop + ": " + top + " " + right + " " + bottom + " " + left; 333 + }); 334 + } 335 + 336 + /** 337 + * Explicitly convert CCW-ordered shorthand values to CW. 338 + * Call this when you've written 4-value shorthands in CCW order. 339 + * 340 + * @param {string} css 341 + * @returns {string} 342 + */ 343 + function convertCCW(css) { 344 + return fixCSS(css, { ccwShorthand: true }); 345 + } 346 + 347 + /** 348 + * Convert only the property names (not values or selectors). 349 + * Useful for partial conversions. 350 + * 351 + * @param {string} css 352 + * @returns {string} 353 + */ 354 + function convertProperties(css) { 355 + var result = css; 356 + for (var prop in PROPERTY_FIXES) { 357 + var escapedProp = prop.replace(/([.*+?^${}()|[\]\\])/g, "\\$1"); 358 + var regex = new RegExp("(^|[;\\s\\n])(" + escapedProp + ")\\s*:", "g"); 359 + result = result.replace(regex, "$1" + PROPERTY_FIXES[prop] + ":"); 360 + } 361 + return result; 362 + } 363 + 364 + // ─── Browser Runtime ─────────────────────────────────────────────────── 365 + 366 + var _debug = false; 367 + var _styleObserver = null; 368 + var _initialised = false; 369 + 370 + function log() { 371 + if (_debug) console.log.apply(console, ["[FixCSS]"].concat([].slice.call(arguments))); 372 + } 373 + 374 + function convertPage() { 375 + if (typeof document === "undefined") return; 376 + 377 + log("Converting page styles…"); 378 + 379 + // Convert existing <style> blocks 380 + var styles = document.querySelectorAll("style"); 381 + for (var i = 0; i < styles.length; i++) { 382 + convertStyleElement(styles[i]); 383 + } 384 + 385 + // Convert existing inline styles 386 + var elements = document.querySelectorAll("[style]"); 387 + for (var j = 0; j < elements.length; j++) { 388 + convertInlineStyle(elements[j]); 389 + } 390 + } 391 + 392 + /** 393 + * Convert a single <style> element's content. 394 + * @private 395 + */ 396 + function convertStyleElement(el) { 397 + if (el.hasAttribute("data-fixcss-converted")) return; 398 + try { 399 + var original = el.textContent; 400 + var converted = fixCSS(original); 401 + if (converted !== original) { 402 + el.textContent = converted; 403 + log("Converted <style>:", el.id || "(unnamed)"); 404 + } 405 + } catch (e) { 406 + log("Error converting <style>: " + e.message); 407 + } 408 + el.setAttribute("data-fixcss-converted", "true"); 409 + } 410 + 411 + /** 412 + * Convert a single element's inline style. 413 + * @private 414 + */ 415 + function convertInlineStyle(el) { 416 + if (el.hasAttribute("data-fixcss-inline-converted")) return; 417 + try { 418 + var original = el.getAttribute("style"); 419 + // Inline styles need to be wrapped for the converter 420 + var wrapped = ".x{" + original + "}"; 421 + var converted = fixCSS(wrapped); 422 + var unwrapped = converted.replace(/\.x\s*\{\s*/, "").replace(/\s*\}\s*$/, ""); 423 + if (unwrapped !== original) { 424 + el.setAttribute("style", unwrapped); 425 + } 426 + } catch (e) { 427 + log("Error converting inline style: " + e.message); 428 + } 429 + el.setAttribute("data-fixcss-inline-converted", "true"); 430 + } 431 + 432 + function start() { 433 + if (_initialised || typeof document === "undefined" || typeof MutationObserver === "undefined") return; 434 + _initialised = true; 435 + 436 + convertPage(); 437 + 438 + // Watch for new <style> elements 439 + _styleObserver = new MutationObserver(function (mutations) { 440 + mutations.forEach(function (mutation) { 441 + mutation.addedNodes.forEach(function (node) { 442 + if (node.nodeType === 1) { 443 + // Element 444 + if (node.tagName === "STYLE") { 445 + convertStyleElement(node); 446 + } 447 + // Check for <style> descendants 448 + var childStyles = node.querySelectorAll ? node.querySelectorAll("style") : []; 449 + for (var i = 0; i < childStyles.length; i++) { 450 + convertStyleElement(childStyles[i]); 451 + } 452 + // Check for inline styles on the element itself 453 + if (node.hasAttribute && node.hasAttribute("style")) { 454 + convertInlineStyle(node); 455 + } 456 + // Check descendants with inline styles 457 + if (node.querySelectorAll) { 458 + var inlineEls = node.querySelectorAll("[style]"); 459 + for (var j = 0; j < inlineEls.length; j++) { 460 + convertInlineStyle(inlineEls[j]); 461 + } 462 + } 463 + } 464 + }); 465 + }); 466 + }); 467 + 468 + _styleObserver.observe(document.documentElement, { 469 + childList: true, 470 + subtree: true, 471 + }); 472 + 473 + log("Observing DOM for new styles…"); 474 + } 475 + 476 + function shutdown() { 477 + if (_styleObserver) { 478 + _styleObserver.disconnect(); 479 + _styleObserver = null; 480 + } 481 + _initialised = false; 482 + log("Shut down."); 483 + } 484 + 485 + /** 486 + * Enable or disable debug logging. 487 + * @param {boolean} [flag=true] 488 + */ 489 + function debug(flag) { 490 + _debug = flag !== false; 491 + log("Debug mode:", _debug ? "ON" : "OFF"); 492 + } 493 + 494 + // ─── Auto-initialise in browser ──────────────────────────────────────── 495 + if (typeof document !== "undefined" && typeof window !== "undefined") { 496 + if (document.readyState === "loading") { 497 + document.addEventListener("DOMContentLoaded", start); 498 + } else { 499 + // DOM already ready 500 + setTimeout(start, 0); 501 + } 502 + } 503 + 504 + // ─── Public API ──────────────────────────────────────────────────────── 505 + return { 506 + // Core conversion 507 + fixCSS: fixCSS, 508 + convertCCW: convertCCW, 509 + convertProperties: convertProperties, 510 + 511 + // Browser runtime 512 + convertPage: convertPage, 513 + start: start, 514 + shutdown: shutdown, 515 + debug: debug, 516 + 517 + // Reference tables 518 + PROPERTY_FIXES: PROPERTY_FIXES, 519 + VALUE_FIXES: VALUE_FIXES, 520 + SELECTOR_FIXES: SELECTOR_FIXES, 521 + DISPLAY_VALUES: DISPLAY_VALUES, 522 + 523 + VERSION: "1.0.0", 524 + }; 525 + });
+31
package.json
··· 1 + { 2 + "name": "fixcss", 3 + "version": "1.0.0", 4 + "description": "Library to rectify mistakes in the design of CSS.", 5 + "keywords": [ 6 + "css", 7 + "fix", 8 + "mistakes", 9 + "csswg", 10 + "convert", 11 + "transpile" 12 + ], 13 + "homepage": "https://tangled.org/vale.rocks/FixCSS", 14 + "bugs": "https://tangled.org/vale.rocks/FixCSS/issues", 15 + "license": "MIT", 16 + "author": "Declan Chidlow (http://vale.rocks)", 17 + "funding": "https://vale.rocks/support", 18 + "files": [ 19 + "fixcss.js", 20 + "fixcss-cli.js" 21 + ], 22 + "main": "fixcss.js", 23 + "bin": "./fixcss-cli.js", 24 + "repository": { 25 + "type": "git", 26 + "url": "git+https://tangled.org/vale.rocks/FixCSS.git" 27 + }, 28 + "scripts": { 29 + "test": "node test/test.js" 30 + } 31 + }
+336
test/test.js
··· 1 + var { fixCSS, convertCCW, convertProperties } = require("../fixcss.js"); 2 + 3 + var passed = 0; 4 + var failed = 0; 5 + var tests = []; 6 + 7 + function test(name, fn) { 8 + tests.push({ name: name, fn: fn }); 9 + } 10 + 11 + function assertEqual(actual, expected, msg) { 12 + if (actual !== expected) { 13 + throw new Error((msg || "Assertion failed") + "\n Expected: " + JSON.stringify(expected) + "\n Actual: " + JSON.stringify(actual)); 14 + } 15 + } 16 + 17 + function assertContains(actual, substring, msg) { 18 + if (actual.indexOf(substring) === -1) { 19 + throw new Error((msg || "String does not contain expected substring") + "\n Expected to contain: " + JSON.stringify(substring) + "\n Actual: " + JSON.stringify(actual)); 20 + } 21 + } 22 + 23 + // ─── Property Renames ──────────────────────────────────────────────────── 24 + 25 + test("#2 animation-count -> animation-iteration-count", function () { 26 + var result = fixCSS(".x { animation-count: 3; }"); 27 + assertContains(result, "animation-iteration-count:"); 28 + }); 29 + 30 + test("#12 z-order -> z-index", function () { 31 + var result = fixCSS(".x { z-order: 10; }"); 32 + assertContains(result, "z-index:"); 33 + }); 34 + 35 + test("#12 depth -> z-index", function () { 36 + var result = fixCSS(".x { depth: 10; }"); 37 + assertContains(result, "z-index:"); 38 + }); 39 + 40 + test("#19 corner-radius -> border-radius", function () { 41 + var result = fixCSS(".x { corner-radius: 5px; }"); 42 + assertContains(result, "border-radius:"); 43 + }); 44 + 45 + test("#21 hyphenate -> hyphens", function () { 46 + var result = fixCSS(".x { hyphenate: auto; }"); 47 + assertContains(result, "hyphens:"); 48 + }); 49 + 50 + test("#24 mix-blend -> mix-blend-mode", function () { 51 + var result = fixCSS(".x { mix-blend: multiply; }"); 52 + assertContains(result, "mix-blend-mode:"); 53 + }); 54 + 55 + test("#24 background-blend -> background-blend-mode", function () { 56 + var result = fixCSS(".x { background-blend: overlay; }"); 57 + assertContains(result, "background-blend-mode:"); 58 + }); 59 + 60 + test("#36 display-type -> display", function () { 61 + var result = fixCSS(".x { display-type: flex; }"); 62 + assertContains(result, "display:"); 63 + // Should not have "display-type" 64 + assertEqual(result.indexOf("display-type"), -1, "display-type should be removed"); 65 + }); 66 + 67 + test("#37 marker-style -> list-style", function () { 68 + var result = fixCSS(".x { marker-style: disc; }"); 69 + assertContains(result, "list-style:"); 70 + }); 71 + 72 + test("#37 marker-style-type -> list-style-type", function () { 73 + var result = fixCSS(".x { marker-style-type: square; }"); 74 + assertContains(result, "list-style-type:"); 75 + }); 76 + 77 + test("#37 marker-style-position -> list-style-position", function () { 78 + var result = fixCSS(".x { marker-style-position: inside; }"); 79 + assertContains(result, "list-style-position:"); 80 + }); 81 + 82 + test("#37 marker-style-image -> list-style-image", function () { 83 + var result = fixCSS(".x { marker-style-image: url(x.png); }"); 84 + assertContains(result, "list-style-image:"); 85 + }); 86 + 87 + test("#46 wrap-shape -> shape-outside", function () { 88 + var result = fixCSS(".x { wrap-shape: circle(50%); }"); 89 + assertContains(result, "shape-outside:"); 90 + }); 91 + 92 + test("#46 shape-wrap -> shape-outside", function () { 93 + var result = fixCSS(".x { shape-wrap: circle(50%); }"); 94 + assertContains(result, "shape-outside:"); 95 + }); 96 + 97 + // ─── Value/Keyword Fixes ─────────────────────────────────────────────── 98 + 99 + test("#1 white-space: no-wrap -> nowrap", function () { 100 + var result = fixCSS(".x { white-space: no-wrap; }"); 101 + assertContains(result, "white-space: nowrap"); 102 + }); 103 + 104 + test("#4 vertical-align: text-middle -> middle", function () { 105 + var result = fixCSS(".x { vertical-align: text-middle; }"); 106 + assertContains(result, "vertical-align: middle"); 107 + }); 108 + 109 + test("#4 vertical-align: x-middle -> middle", function () { 110 + var result = fixCSS(".x { vertical-align: x-middle; }"); 111 + assertContains(result, "vertical-align: middle"); 112 + }); 113 + 114 + test("#17 current-color -> currentColor", function () { 115 + var result = fixCSS(".x { color: current-color; }"); 116 + assertContains(result, "currentColor"); 117 + }); 118 + 119 + test("#17 accent-color -> accentColor", function () { 120 + var result = fixCSS(".x { color: accent-color; }"); 121 + assertContains(result, "accentColor"); 122 + }); 123 + 124 + test("#17 accent-color-text -> accentColorText", function () { 125 + var result = fixCSS(".x { color: accent-color-text; }"); 126 + assertContains(result, "accentColorText"); 127 + }); 128 + 129 + test("::placeholder-text does NOT double-convert via :placeholder match", function () { 130 + var result = fixCSS("::placeholder-text { color: gray; }"); 131 + // Should be ::placeholder (NOT ::placeholder-shown) 132 + assertContains(result, "::placeholder"); 133 + assertEqual(result.indexOf("::placeholder-shown"), -1, "::placeholder-text should become ::placeholder, not ::placeholder-shown"); 134 + }); 135 + 136 + test("#17 current-color in multiple properties", function () { 137 + var result = fixCSS(".x { color: current-color; background: current-color; }"); 138 + assertContains(result, "currentColor"); 139 + // Both occurrences should be replaced 140 + var first = result.indexOf("currentColor"); 141 + var last = result.lastIndexOf("currentColor"); 142 + assertEqual(first !== last, true, "Both occurrences should be replaced"); 143 + }); 144 + 145 + // ─── Function Fixes ────────────────────────────────────────────────────── 146 + 147 + test("#22 4-arg rgb() -> rgba()", function () { 148 + var result = fixCSS(".x { color: rgb(255, 0, 0, 0.5); }"); 149 + assertContains(result, "rgba(255, 0, 0, 0.5)"); 150 + assertEqual(result.indexOf("rgb("), -1, "rgb( with 4 args should become rgba("); 151 + }); 152 + 153 + test("#22 4-arg hsl() -> hsla()", function () { 154 + var result = fixCSS(".x { color: hsl(0, 100%, 50%, 0.5); }"); 155 + assertContains(result, "hsla(0, 100%, 50%, 0.5)"); 156 + }); 157 + 158 + test("#22 3-arg rgb() stays as rgb()", function () { 159 + var result = fixCSS(".x { color: rgb(255, 0, 0); }"); 160 + assertContains(result, "rgb(255, 0, 0)"); 161 + }); 162 + 163 + test("#43 span(N) -> span N in grid", function () { 164 + var result = fixCSS(".x { grid-column: span(2); }"); 165 + assertContains(result, "span 2"); 166 + }); 167 + 168 + test("#35 fr units in flex shorthand", function () { 169 + var result = fixCSS(".x { flex: 1fr 1fr 200px; }"); 170 + assertContains(result, "flex: 1 1 200px"); 171 + }); 172 + 173 + // ─── Selector Fixes ────────────────────────────────────────────────────── 174 + 175 + test("#29 :void -> :empty", function () { 176 + var result = fixCSS(":void { display: none; }"); 177 + assertContains(result, ":empty"); 178 + assertEqual(result.indexOf(":void"), -1, ":void should be replaced"); 179 + }); 180 + 181 + test("#40 ::placeholder-text -> ::placeholder", function () { 182 + var result = fixCSS("::placeholder-text { color: gray; }"); 183 + assertContains(result, "::placeholder"); 184 + assertEqual(result.indexOf("::-placeholder-text"), -1, "::placeholder-text should be replaced"); 185 + }); 186 + 187 + test("#40 :placeholder -> :placeholder-shown", function () { 188 + var result = fixCSS("input:placeholder { background: yellow; }"); 189 + assertContains(result, ":placeholder-shown"); 190 + }); 191 + 192 + // ─── Shorthand Fixes ───────────────────────────────────────────────────── 193 + 194 + test("#8 single-value background-size duplicates", function () { 195 + var result = fixCSS(".x { background-size: 50%; }"); 196 + assertContains(result, "background-size: 50% 50%"); 197 + }); 198 + 199 + test("#8 background-size: cover stays single", function () { 200 + var result = fixCSS(".x { background-size: cover; }"); 201 + assertContains(result, "background-size: cover"); 202 + // Should not have 'cover cover' 203 + assertEqual(result.indexOf("cover cover"), -1); 204 + }); 205 + 206 + test("#8 single-value translate() duplicates", function () { 207 + var result = fixCSS(".x { transform: translate(10px); }"); 208 + assertContains(result, "translate(10px, 10px)"); 209 + }); 210 + 211 + test("#8 translate(0) stays unchanged", function () { 212 + var result = fixCSS(".x { transform: translate(0); }"); 213 + // 0 or none stays as is 214 + assertContains(result, "translate(0)"); 215 + }); 216 + 217 + test("#9 background-position axis order swap (V H -> H V)", function () { 218 + var result = fixCSS(".x { background-position: top left; }"); 219 + // top (vertical) left (horizontal) -> left top (horizontal first) 220 + assertContains(result, "background-position: left top"); 221 + }); 222 + 223 + test("#9 border-spacing axis order swap (V H -> H V)", function () { 224 + var result = fixCSS(".x { border-spacing: 5px 10px; }"); 225 + assertContains(result, "border-spacing: 10px 5px"); 226 + }); 227 + 228 + // ─── CCW Shorthand Conversion ──────────────────────────────────────────── 229 + 230 + test("#11 CCW 4-value margin conversion", function () { 231 + var result = convertCCW(".x { margin: 1px 2px 3px 4px; }"); 232 + // CCW: top=1, left=2, bottom=3, right=4 -> CW: top=1, right=4, bottom=3, left=2 233 + assertContains(result, "margin: 1px 4px 3px 2px"); 234 + }); 235 + 236 + test("#11 CCW 4-value padding conversion", function () { 237 + var result = convertCCW(".x { padding: 10px 20px 30px 40px; }"); 238 + assertContains(result, "padding: 10px 40px 30px 20px"); 239 + }); 240 + 241 + // ─── Unicode Range Fixes ───────────────────────────────────────────────── 242 + 243 + test("#25 unicode-range: u+0001-u+00c8 -> U+0001-U+00C8", function () { 244 + var result = fixCSS("@font-face { unicode-range: u+0001-u+00c8; }"); 245 + assertContains(result, "U+0001-U+00C8"); 246 + }); 247 + 248 + test("#25 unicode-range: u+0025 -> U+0025", function () { 249 + var result = fixCSS("@font-face { unicode-range: u+0025; }"); 250 + assertContains(result, "U+0025"); 251 + }); 252 + 253 + // ─── Display Value Fixes ───────────────────────────────────────────────── 254 + 255 + test("#37 display: marked-block -> list-item", function () { 256 + var result = fixCSS(".x { display-type: marked-block; }"); 257 + assertContains(result, "display: list-item"); 258 + }); 259 + 260 + // ─── String Protection ─────────────────────────────────────────────────── 261 + 262 + test("Strings are not modified", function () { 263 + var result = fixCSS('.x::after { content: "current-color: red; corner-radius: 5px"; }'); 264 + assertContains(result, 'content: "current-color: red; corner-radius: 5px"'); 265 + // The content string should remain untouched 266 + }); 267 + 268 + test("Single-quoted strings are not modified", function () { 269 + var result = fixCSS(".x::after { content: 'z-order: 999'; }"); 270 + assertContains(result, "content: 'z-order: 999'"); 271 + }); 272 + 273 + // ─── Comment Protection ────────────────────────────────────────────────── 274 + 275 + test("Comments are preserved", function () { 276 + var result = fixCSS("/* This is a comment with z-order: 10 */ .x { color: red; }"); 277 + assertContains(result, "/* This is a comment with z-order: 10 */"); 278 + }); 279 + 280 + // ─── Multiple Rule Sets ────────────────────────────────────────────────── 281 + 282 + test("Multiple rules are all converted", function () { 283 + var result = fixCSS(".a { corner-radius: 5px; } .b { z-order: 10; }"); 284 + assertContains(result, "border-radius:"); 285 + assertContains(result, "z-index:"); 286 + }); 287 + 288 + // ─── convertProperties (property-only mode) ────────────────────────────── 289 + 290 + test("convertProperties only changes property names", function () { 291 + var result = convertProperties(".x { corner-radius: 5px; color: current-color; }"); 292 + assertContains(result, "border-radius:"); 293 + // Values should be left alone in property-only mode 294 + assertContains(result, "current-color"); // unchanged 295 + }); 296 + 297 + // ─── Edge Cases ────────────────────────────────────────────────────────── 298 + 299 + test("Empty CSS returns empty", function () { 300 + var result = fixCSS(""); 301 + assertEqual(result, ""); 302 + }); 303 + 304 + test("CSS with no fixes returns unchanged structurally", function () { 305 + var result = fixCSS(".x { color: red; font-size: 16px; }"); 306 + assertEqual(result, ".x { color: red; font-size: 16px; }"); 307 + }); 308 + 309 + test("!important flag preserved during conversion", function () { 310 + var result = fixCSS(".x { z-order: 10 !important; }"); 311 + assertContains(result, "!important"); 312 + assertContains(result, "z-index:"); 313 + }); 314 + 315 + // ─── Run Tests ─────────────────────────────────────────────────────────── 316 + 317 + console.log("FixCSS Test Suite\n" + "=".repeat(60) + "\n"); 318 + 319 + tests.forEach(function (t) { 320 + try { 321 + t.fn(); 322 + console.log(" ✓ " + t.name); 323 + passed++; 324 + } catch (e) { 325 + console.log(" ✗ " + t.name); 326 + console.log(" " + e.message.replace(/\n/g, "\n ")); 327 + failed++; 328 + } 329 + }); 330 + 331 + console.log("\n" + "=".repeat(60)); 332 + console.log("Results: " + passed + " passed, " + failed + " failed, " + tests.length + " total"); 333 + 334 + if (failed > 0) { 335 + process.exit(1); 336 + }