diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..e50d366 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,108 @@ +/*eslint sort-keys: 2*/ +/*eslint object-property-newline: 2*/ +/*eslint quote-props: [2, "consistent"]*/ + +module.exports = { + env: { + es6: true, + node: true, + }, + extends: 'eslint:recommended', + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + rules: { + 'array-bracket-spacing': [2, 'never'], + 'array-element-newline': [2, 'consistent'], + 'arrow-parens': [2, 'as-needed'], + 'arrow-spacing': 2, + 'brace-style': [2, 'stroustrup'], + 'camelcase': [2, { + ignoreDestructuring: true, + properties: 'never', + }], + 'comma-dangle': [2, { + arrays: 'always-multiline', + functions: 'never', + objects: 'always-multiline', + }], + 'comma-spacing': [2, { + after: true, + before: false, + }], + 'curly': 2, + 'eol-last': [2, 'always'], + 'eqeqeq': 2, + 'key-spacing': [2, { + afterColon: true, + beforeColon: false, + }], + 'keyword-spacing': 2, + 'linebreak-style': [2, 'unix'], + 'multiline-comment-style': [2, 'starred-block'], + 'no-console': 2, + 'no-dupe-keys': 2, + 'no-else-return': 2, + 'no-empty': [2, { + allowEmptyCatch: true, + }], + 'no-lonely-if': 2, + 'no-multi-spaces': 2, + 'no-multiple-empty-lines': [2, { + max: 2, + maxBOF: 1, + maxEOF: 1, + }], + 'no-new-object': 2, + 'no-template-curly-in-string': 2, + 'no-tabs': 2, + 'no-throw-literal': 2, + 'no-trailing-spaces': 2, + 'no-unneeded-ternary': 2, + 'no-unused-expressions': [2, {allowShortCircuit: true}], + 'no-unused-vars': [2, { + args: 'all', + argsIgnorePattern: '^(req|res|next)$|^_', + varsIgnorePattern: '^_$', + }], + 'no-useless-call': 2, + 'no-useless-concat': 2, + 'no-useless-return': 2, + 'no-var': 2, + 'object-curly-newline': [2, {consistent: true}], + 'object-curly-spacing': [2, 'never'], + 'object-shorthand': [2, 'properties'], + 'operator-linebreak': [2, 'before', { + overrides: { + ':': 'ignore', + '?': 'ignore', + }, + }], + 'prefer-arrow-callback': 2, + 'prefer-const': [2, {destructuring: 'all'}], + 'prefer-destructuring': [2, { + array: false, + object: true, + }], + 'prefer-object-spread': 2, + 'quote-props': [2, 'as-needed'], + 'quotes': [2, 'single', { + allowTemplateLiterals: true, + avoidEscape: true, + }], + 'semi': [2, 'always'], + 'space-before-blocks': 2, + 'space-before-function-paren': [2, { + anonymous: 'always', + asyncArrow: 'always', + named: 'never', + }], + 'space-unary-ops': [2, { + nonwords: false, + overrides: {'!': true}, + words: true, + }], + 'yoda': 2, + }, +}; diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..97a1a66 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +tests/ export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore +phpunit.xml.dist export-ignore +package-lock.json -diff diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml new file mode 100644 index 0000000..5e896c1 --- /dev/null +++ b/.github/workflows/php.yml @@ -0,0 +1,47 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + + php-api: + runs-on: ubuntu-latest + + strategy: + matrix: + php-versions: ['8.1', '8.4'] + + steps: + - uses: actions/checkout@v2 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + tools: composer:v2 + - name: Validate composer.json and composer.lock + run: composer validate --strict + - name: Install dependencies + run: composer install --prefer-dist --no-progress + - name: Lint + run: composer run-script lint + - name: Test + run: composer run-script test + + js-api: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v3 + with: + node-version: 18 + - name: Install dependencies + run: npm install + - name: Lint + run: npm run lint + - name: Check JS + run: npm run types + - name: Test + run: npm run test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1be7b0a --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +_* +.tm_properties +.phpunit.result.cache +phpunit.xml +composer.lock +vendor +node_modules diff --git a/Aliases.ini b/Aliases.ini deleted file mode 100644 index bdc4bc3..0000000 --- a/Aliases.ini +++ /dev/null @@ -1,236 +0,0 @@ -;---------------------------------------------------------------------------- -; Enable/disable aliases by commenting them (with semicolons) -;---------------------------------------------------------------------------- - -;---------------------------------------------------------------------------- -;-- Property aliases - -[properties] - - ; Animations - animation[] = -webkit-animation - animation[] = -moz-animation - animation[] = -ms-animation - animation-delay[] = -webkit-animation-delay - animation-delay[] = -moz-animation-delay - animation-delay[] = -ms-animation-delay - animation-direction[] = -webkit-animation-direction - animation-direction[] = -moz-animation-direction - animation-direction[] = -ms-animation-direction - animation-duration[] = -webkit-animation-duration - animation-duration[] = -moz-animation-duration - animation-duration[] = -ms-animation-duration - animation-fill-mode[] = -webkit-animation-fill-mode - animation-fill-mode[] = -moz-animation-fill-mode - animation-fill-mode[] = -ms-animation-fill-mode - animation-iteration-count[] = -webkit-animation-iteration-count - animation-iteration-count[] = -moz-animation-iteration-count - animation-iteration-count[] = -ms-animation-iteration-count - animation-name[] = -webkit-animation-name - animation-name[] = -moz-animation-name - animation-name[] = -ms-animation-name - animation-play-state[] = -webkit-animation-play-state - animation-play-state[] = -moz-animation-play-state - animation-play-state[] = -ms-animation-play-state - animation-timing-function[] = -webkit-animation-timing-function - animation-timing-function[] = -moz-animation-timing-function - animation-timing-function[] = -ms-animation-timing-function - - ; Backface visibility - backface-visibility[] = -webkit-backface-visibility - backface-visibility[] = -moz-backface-visibility - backface-visibility[] = -ms-backface-visibility - - ; Background clip - background-clip[] = -webkit-background-clip - background-clip[] = -moz-background-clip - - ; Background origin - background-origin[] = -webkit-background-origin - background-origin[] = -moz-background-origin - - ; Background size - background-size[] = -webkit-background-size - background-size[] = -moz-background-size - - ; Border radius - border-radius[] = -webkit-border-radius - border-radius[] = -moz-border-radius - border-top-left-radius[] = -webkit-border-top-left-radius - border-top-left-radius[] = -moz-border-radius-topleft - border-top-right-radius[] = -webkit-border-top-right-radius - border-top-right-radius[] = -moz-border-radius-topright - border-bottom-left-radius[] = -webkit-border-bottom-left-radius - border-bottom-left-radius[] = -moz-border-radius-bottomleft - border-bottom-right-radius[] = -webkit-border-bottom-right-radius - border-bottom-right-radius[] = -moz-border-radius-bottomright - - ; Border-image - border-image[] = -webkit-border-image - border-image[] = -moz-border-image - border-image[] = -o-border-image - - ; Flexbox (old, but supported implementation) - box-align[] = -webkit-box-align - box-align[] = -moz-box-align - box-align[] = -ms-box-align - box-direction[] = -webkit-box-direction - box-direction[] = -moz-box-direction - box-direction[] = -ms-box-direction - box-flex[] = -webkit-box-flex - box-flex[] = -moz-box-flex - box-flex[] = -ms-box-flex - box-orient[] = -webkit-box-orient - box-orient[] = -moz-box-orient - box-orient[] = -ms-box-orient - box-pack[] = -webkit-box-pack - box-pack[] = -moz-box-pack - box-pack[] = -ms-box-pack - - ; Box shadow - box-shadow[] = -webkit-box-shadow - box-shadow[] = -moz-box-shadow - - ; Box sizing - box-sizing[] = -webkit-box-sizing - box-sizing[] = -moz-box-sizing - - ; Columns - columns[] = -webkit-columns - columns[] = -moz-columns - column-count[] = -webkit-column-count - column-count[] = -moz-column-count - column-fill[] = -webkit-column-fill - column-fill[] = -moz-column-fill - column-gap[] = -webkit-column-gap - column-gap[] = -moz-column-gap - column-rule[] = -webkit-column-rule - column-rule[] = -moz-column-rule - column-rule-style[] = -webkit-column-rule-style - column-rule-style[] = -moz-column-rule-style - column-rule-width[] = -webkit-column-rule-width - column-rule-width[] = -moz-column-rule-width - column-rule-style[] = -webkit-column-rule-style - column-rule-style[] = -moz-column-rule-style - column-rule-color[] = -webkit-column-rule-color - column-rule-color[] = -moz-column-rule-color - column-span[] = -webkit-column-span - column-span[] = -moz-column-span - column-width[] = -webkit-column-width - column-width[] = -moz-column-width - - ; Hyphens - hyphens[] = -webkit-hyphens - hyphens[] = -moz-hyphens - hyphens[] = -ms-hyphens - - ; Perspective - perspective[] = -webkit-perspective - perspective[] = -moz-perspective - perspective[] = -ms-perspective - perspective-origin[] = -webkit-perspective-origin - perspective-origin[] = -moz-perspective-origin - perspective-origin[] = -ms-perspective-origin - - ; Tab size - tab-size[] = -webkit-tab-size - tab-size[] = -moz-tab-size - tab-size[] = -o-tab-size - - ; Text decoration - text-decoration-color[] = -moz-text-decoration-color - text-decoration-line[] = -moz-text-decoration-line - text-decoration-style[] = -moz-text-decoration-style - - ; Transforms - transform[] = -webkit-transform - transform[] = -moz-transform - transform[] = -ms-transform - transform[] = -o-transform - transform-style[] = -webkit-transform-style - transform-style[] = -moz-transform-style - transform-style[] = -ms-transform-style - - ; Transitions - transition[] = -webkit-transition - transition[] = -moz-transition - transition[] = -ms-transition - transition[] = -o-transition - transition-delay[] = -webkit-transition-delay - transition-delay[] = -moz-transition-delay - transition-delay[] = -ms-transition-delay - transition-delay[] = -o-transition-delay - transition-duration[] = -webkit-transition-duration - transition-duration[] = -moz-transition-duration - transition-duration[] = -ms-transition-duration - transition-duration[] = -o-transition-duration - transition-property[] = -webkit-transition-property - transition-property[] = -moz-transition-property - transition-property[] = -ms-transition-property - transition-property[] = -o-transition-property - transition-timing-function[] = -webkit-transition-timing-function - transition-timing-function[] = -moz-transition-timing-function - transition-timing-function[] = -ms-transition-timing-function - transition-timing-function[] = -o-transition-timing-function - - ; User select (non standard) - user-select[] = -webkit-user-select - user-select[] = -moz-user-select - user-select[] = -ms-user-select - user-select[] = -o-user-select - user-select[] = user-select - - -;---------------------------------------------------------------------------- -;-- Property:value aliases - -[values] - - ; Flexbox - display:box[] = -webkit-box - display:box[] = -moz-box - display:box[] = -ms-box - - -;---------------------------------------------------------------------------- -;-- Function aliases - -[functions] - - ; Calc - calc[] = -webkit-calc - calc[] = -moz-calc - - ; Element - element[] = -moz-element - - ; Gradients - linear-gradient[] = -webkit-linear-gradient - linear-gradient[] = -moz-linear-gradient - linear-gradient[] = -ms-linear-gradient - linear-gradient[] = -o-linear-gradient - radial-gradient[] = -webkit-radial-gradient - radial-gradient[] = -moz-radial-gradient - radial-gradient[] = -ms-radial-gradient - radial-gradient[] = -o-radial-gradient - - ; Repeating gradients - repeating-linear-gradient[] = -webkit-repeating-linear-gradient - repeating-linear-gradient[] = -moz-repeating-linear-gradient - repeating-linear-gradient[] = -ms-repeating-linear-gradient - repeating-linear-gradient[] = -o-repeating-linear-gradient - repeating-radial-gradient[] = -webkit-repeating-radial-gradient - repeating-radial-gradient[] = -moz-repeating-radial-gradient - repeating-radial-gradient[] = -ms-repeating-radial-gradient - repeating-radial-gradient[] = -o-repeating-radial-gradient - - -;---------------------------------------------------------------------------- -;-- @rule aliases - -[at-rules] - keyframes[] = -webkit-keyframes - keyframes[] = -moz-keyframes - keyframes[] = -ms-keyframes - - diff --git a/CHANGELOG b/CHANGELOG deleted file mode 100644 index 3b6600d..0000000 --- a/CHANGELOG +++ /dev/null @@ -1,113 +0,0 @@ -1.4.2 ------ -Fixed bug with @import statement parsing -Some minor under the hood changes - - -1.4.1 ------ -Added command line application -Added 'rewrite_import_urls' option - Ability to rewrite relative url references inside imported css files -Added Prepend.css - Optionally prepend css to every input -Fix for issue #21 -Reorganized aliases file with some additions -Initial-values updated -Updated csscrush::string method to correctly handle import statements - - -1.4 ---- -Added initial-keyword plugin (shim for the CSS3 keyword) -Added inline method (Issue #18) -Added ability to escape declarations from aliasing or plugins by prefixing with tilde -Added procedural style public API to mirror the static class API -Deprecated @variables syntax for @define. @variables still supported -Adjusted color functions to accept a space delimiter (as well as comma) in the arguments list -Surpressed some benign PHP warning messages -Some internal cleaning up -Disabled IE6 min-height plugin by default - - -1.3.6 ------ -Improved color functions -Added a-adjust function for altering a color's opacity -Deprecated hsl-adjust function (you can use nested color functions instead) -Added the ability to use local versions of alias and plugin files so pull updates don't clobber local settings - - -1.3.5 ------ -Added hook system for plugins -Plugins split into seperate files -Aliases and Plugins files renamed with '.ini' file extensions to be editor friendly -Added opacity plugin -Updated filter plugin -Fixed nested custom function parsing (issue #14) - - -1.3.4 ------ -Added output_filename option -Added vendor_target option -Renamed 'macros' to the more general 'plugins' and split them into their own files -Removed superfluous outer containing directory (update your include paths) - - -1.3.3 ------ -Fixed regression with absolute URL file imports (issue #12) -Fixed minification bug (issue #13) - - -1.3.2 ------ -Updated variable syntax -Fixed minification bug - - -1.3.1 ------ -Added support for svg and svgz data uris -Added animation shorthand alias -Added user-select alias - - -1.3 ---- -Added the public function CssCrush::string for processing raw strings of CSS -Added color functions -Added aliases for IE10 - - -1.2 ---- -Rewritten the file importer - - -1.1 ---- -Added global variables support -Added support for variable interpolation within string literals -Added 'tag' method for outputting an html link tag instead of returning a filename -Added values aliases, dynamic 'runtime' variables -Added RGBA macro -Added IE clip macro -Added data uri function -Minor correction to WAMP support -Minor fix to rule API - - -1.0 ---- -Major refactoring -Custom functions -Optional boilerplate -Double colon syntax shim -Resolved document root issues -Minification improvements - - -0.9 ---- -Initial release diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3ee1d67 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,356 @@ +## 4.0.0 (2022-01-01) + +* Raised php requirement to >= 7 +* Added support for utf-8 markup in data-uri *.svg files. (@xerc) +* Fixed issue with phpini.memory_limit (@GalileoWebagentur) +* Updated vendor aliases. + + +******************************************************************** + +## 3.0.0 (2019-12-31) + +* Raised php requirement to >= 5.6 +* Removed `csscrush_version()` +* Removed `csscrush_add_function()` (can use plugin instead). +* Added `csscrush_plugin()` with simplified plugin api. +* Added `import_path` option. Additional paths to search when resolving relative imports. +* Added support for non-CSS declaration values via backticks (for custom property values). +* Custom properties `--*` now preserve case. +* Updated vendor aliases. +* Moved loop plugin to core. +* Removed `@in` directive. +* Removed `@settings` directive and its api. +* Removed legacy IE plugins. +* Removed hsl2hex, initial, noise, rem, px2em, color and text-align plugins. +* Combined svg plugins (svg-gradients and svg). +* Removed `percent` function. + + +******************************************************************** + +## 2.4.0 (2015-07-30) + +* Added simple value checking to `@ifset`. +* Updated vendor aliases. +* Various fixes and under the hood improvements. + +## 2.3.0 (2015-02-16) + +* Added support for function calls on media query lists. +* Added package.json for node package managers. +* Added `previous`/`next` context keywords to `query()` function. +* Removed legacy-flexbox plugin. +* Removed `disable` option. Renamed `enable` option to `plugins`, old name still supported. +* Removing trace option (SASS debug-info is obsolete) and related functionality. CSS source maps are now well supported. +* Color functions now return nothing if the color argument is invalid. +* Improvements to logging and error reporting. +* Various bug fixes. + +## 2.2.0 (2014-06-17) + +* Rule nesting now works without `@in` directives. +* Added `csscrush_add_function()` as a simple way of adding custom functions without plugins. +* Added alternative directive names: `@set`/`@ifset` for `@define`/`@ifdefine` and `@selector` for `@selector-alias`. +* Added support for a command line config file (`crushfile.php`). +* Added `Util::readConfigFile()` method to enable easier configuration sharing between different workflows; esp. command-line and server. +* Protocoled `@import` directives are now hoisted to the top of output. +* Default output filename now uses `.crush.css` suffix only when outputting to the same directory as input. Otherwise a regular `.css` suffix is used. +* Updated vendor aliases. +* Removed math shorthand syntax. +* Deprecated `@in` directives. Supported until at-least 3.x. +* Deprecated `@define`/`@ifdefine`/`@selector-alias` in favour of new directive names. Supported until at-least 3.x. +* Deprecated the static api methods in favour of the `csscrush_*` functions. Supported until at-least 3.x. + +## 2.1.0 (2014-03-21) + +* Added HHVM support (HHVM >= 2.4) +* Added Travis CI support. +* Added custom color keywords plugin. +* Added text-align plugin for polyfilling the direction sensitive text-align values, start and end. +* Added selector splat aliases which expand based on arguments. +* Added settings interface for plugins and CSS environment. Old variable based settings (as used in rem and px2em plugins) are now deprecated. +* Added library docs to repository. +* Added unit argument to the math function. +* Deprecated bare parens math e.g. `()` due to their use in developing CSS specs. +* Removed `-ms-` gradient aliases. +* Renamed plugin `hsl-to-hex` to `hsl2hex`. +* Updated plugin API. +* Improved feedback for command line watched files. +* Removed date modified from default boilerplate. +* Made git version available for use in boilerplates. +* Reported version now uses `git describe` style output if available. +* Changed base IO class to use non-static methods. +* Numerous under the hood improvements. + +## 2.0.0 (2013-11-2) + +* Raised PHP version requirement to PHP 5.3.1. +* Library code (excluding API functions) is now namespaced. +* Added loop plugin: For...in loops with lists and generator functions. +* Added ARIA plugin for working with aria roles states and properties. +* Added forms plugin: pseudo classes for working with forms. +* Removed legacy IE plugins (ie-clip, ie-filter, ie-min-height, rgba-fallback) and spiffing. +* Added parsing for single line variable definitions e.g. `@define col-width 30px;` +* Added support for relative input/output file paths (based on the current excecuting script path). +* Added support for protocol-relative (//) URLs. +* Removed `csscrush_clearcache()` function – Its functionality can be easily replicated in plain PHP since all output files have a '.crush.css' file extension. +* Removed `csscrush_globalvars()` function. Use `csscrush_set()` instead. +* Added `stat_dump` option for saving stats and variables used to a file in json format. +* Added `asset_dir` option for directing generated svg and image files. +* Deprecated and removed the *-local.ini now there is a better ways of augmenting the default aliases. +* If `formatter` option is set will now override the `minify` option (setting it to false) +* Now using a PSR-3 compatible logging interface (default implementation can be overridden). +* Better error reporting for syntax errors. +* Various Bug fixes. + + +******************************************************************** + +## 1.11.0 (2013-8-3) + +* Added source map support according to the Source Map v3 proposal (boolean option `source-map`). +* Compile times are now 20-30% reduced. +* Added support for fragment calls within fragment definitions (Issue #48). +* Added check and recovery for overly conservative ini settings. +* The block nesting parent symbol can now be used multiple times (useful for adjacent/general sibling combinations). +* Command utility now supports the `trace` option. +* Custom formatter callbacks have been simplified. +* Simplified the `csscrush_stat()` function signature. +* Added command line utility alias for composer's vendor/bin directory. +* Removed Plugins.ini (use `csscrush_set()` instead). +* Removed Prepend.css. +* Various refactoring for cleaner under-the-hood APIs. + +## 1.10.0 (2013-5-18) + +* Added SVG plugin for defining and generating SVG files/data URIs in CSS. +* Added Canvas plugin for image generation and manipulation (requires GD extension). +* Added rem and px2em plugins. +* Added ease plugin for expanded easing keywords. +* Command line utility now has a `--watch` option for automatic compiling when a file is updated. +* `vendor_target` option now accepts an array of targets. +* Added `@name` in-rule directive for more robust rule referencing. +* Added grouping for function aliases so multiple related functions (e.g. gradients) can now be + applied to one value. +* Rule references previously looked for the closest previous match. This behaviour has been changed + to a 'last wins' match to be more consistent with the way CSS works. This may affect users of `@extend` + or the `query()` function. +* Added `-i` alias to `--file` option for the command line utility. +* Removed data-* properties. +* Nested rules that use the parent symbol (&) can now work in conjunction with the rooting symbol (^). +* Fixed issue with empty imported files not registering. +* Various bug fixes. + +## 1.9.1 (2013-1-31) + +* Added noise plugin (noise/texture generating functions). +* Resolved issues #42 and #43. +* Fixed command line context option. +* Fixed error notice with no enabled plugins in Plugins.ini file. +* Updated aliases file. + +## 1.9 (2013-1-12) + +* Added flexbox aliases for both 2009 and 2012 edition specs. +* Added a legacy-flexbox plugin for auto-generating the flexbox 2009 spec equivilant properties. +* Updated selector aliases to take arguments at runtime. +* Updated plugin API to use distinct "enable" and "disable" handlers. +* `disable` option is now resolved before the `enable` option so you can easily disable all plugins + and then specify the plugins you want to apply. +* Added functions API for defining custom functions inside plugins. +* Improved gradient function aliasing to handle new angle keywords (to left, at center, etc.). +* Added svg-gradients plugin for simulating CSS3 gradients with data-uris. +* Added `formatter` option for un-minified output. Possible values (custom formatters can also be defined): + * "block" (default) - Rules are block formatted. + * "single-line" - Rules are printed in single lines. + * "padded" - Rules are printed in single lines with right padded selectors. + Custom formatters can also be defined. +* Added `newlines` option to set the style of newlines in output. Possible values: + * "use-platform" (default) + * "unix" + * "windows" or "win" +* Updated command line utility to use the new options. +* Property/value aliases expanded and renamed as declaration aliases. +* Classes now loaded via an autoloader, also some other refactoring for moving towards PSR-0 compliance. + +## 1.8.0 (2012-11-13) + +* Added selector aliasing with the `@selector-alias` directive. +* Added `output_dir` option for specifying the destination of compiled files. +* Added `doc_root` option for working around problems with server aliases or path rewrites. +* Added viewport @-rule aliases. +* `debug` option renamed to `minify`; `debug` option will still work as before but is deprecated. +* `minify` option takes an optional array of advanced minification parameters. Possible values: + * `colors` +* Expanded `trace` option to take an optional array of log parameters. Possible values: + * `stubs` + * `selector_count` + * `errors` + * `compile_time` +* Added `CssCrush::stat` method to retrieve logged parameters. +* Improved cross OS support. +* Improved minification. +* Major refactoring. + +## 1.7.0 (2012-9-28) + +* Added `trace` option to output SASS compatible debug-info stubs for use with tools like FireSass. +* Added `@ifdefine` directive for dynamically including/excluding parts of a CSS file based on the + existence of variables. +* Updated plugin API. +* Added options for enabling and disabling plugins at runtime. +* Added property sorter plugin. +* Added support for SASS-like @include/@extend syntax for invoking mixins and extends. +* Boilerplate option now accepts a filename string as a boilerplate template. +* `CssCrush::string` method now uses document\_root as a default context for finding linked resources. +* Updated command line appication. +* Updated aliases and initial value files. +* Fixed parsing issue introduced in 1.6.1. + +## 1.6.1 (2012-8-22) + +* Resolved issues #34 and #35. + +## 1.6.0 (2012-8-1) + +* Inheritance model improved to support adoption of pseudo classes and elements (see wiki). +* Added rule self-referencing function `this()` and complimentary data-* properties. +* Added rule referencing function `query()`. +* Added default value argument for variables. +* Added `hsl-adjust()` and `hsla-adjust()` color functions. +* Mixin and fragment `arg()` function can now be nested. +* Commas are now optional when specifying arguments for most custom functions. +* Double-colon plugin moved to core. +* Option `rewrite_import_urls` now defaults to true. + +## 1.5.3 (2012-6-13) + +* Refactoring. +* Fixed some test cases. + +## 1.5.2 (2012-6-8) + +* Resolved issue #32. +* `CssCrush::inline` method now defaults to not printing a boilerplate. +* Updated aliases file. + +## 1.5.1 (2012-6-1) + +* Extended mixins to work with abstract rules and regular rules. +* Fixed issue with selector grouping and inheritance in combination. + +## 1.5.0 (2012-5-21) + +* New feature: Rule inheritance / abstract rules. +* New feature: Block nesting. +* New feature: Mixins. +* New feature: Fragments. +* Abstracted IO interface. +* Added some error reporting. +* Added spiffing.css plugin. +* `CssCrush::tag` method now uses media type 'all' by default. +* Updated alias and initial-value tables. +* Internal refactoring. +* Resolved issues #23, #24, #27, #28 and #29. + +## 1.4.2 (2012-3-14) + +* Fixed bug with @import statement parsing. +* Some minor under the hood changes. + +## 1.4.1 (2012-2-10) + +* Added command line application. +* Added `rewrite_import_urls` option - Ability to rewrite relative url references inside imported css files. +* Added Prepend.css - Optionally prepend css to every input. +* Fix for issue #21. +* Reorganized aliases file with some additions. +* Initial-values updated. +* Updated `CssCrush::string` method to correctly handle import statements. + +## 1.4.0 (2012-1-24) + +* Added initial-keyword plugin (shim for the CSS3 keyword). +* Added inline method (Issue #18). +* Added ability to escape declarations from aliasing or plugins by prefixing with tilde. +* Added procedural style public API to mirror the static class API. +* Deprecated `@variables` directive for `@define`. @variables still supported for next few releases. +* Adjusted color functions to accept a space delimiter (as well as comma) in the arguments list. +* Surpressed some benign PHP warning messages. +* Some internal cleaning up. +* Disabled IE6 min-height plugin by default. + +## 1.3.6 (2011-11-9) + +* Improved color functions. +* Added `a-adjust()` function for altering a color's opacity. +* Deprecated hsl-adjust function (you can use nested color functions instead). +* Added the ability to use local versions of alias and plugin files so pull updates don't clobber local settings. + +## 1.3.5 (2011-11-8) + +* Added hook system for plugins. +* Plugins split into seperate files. +* Aliases and Plugins files renamed with '.ini' file extensions to be editor friendly. +* Added opacity plugin. +* Updated filter plugin. +* Fixed nested custom function parsing (issue #14). + +## 1.3.4 (2011-10-29) + +* Added output_filename option. +* Added vendor_target option. +* Renamed 'macros' to the more general 'plugins' and split them into their own files. +* Removed superfluous outer containing directory (update your include paths). + +## 1.3.3 (2011-10-28) + +* Fixed regression with absolute URL file imports (issue #12). +* Fixed minification bug (issue #13). + +## 1.3.2 (2011-10-18) + +* Updated variable syntax. +* Fixed minification bug. + +## 1.3.1 (2011-10-9) + +* Added support for svg and svgz data uris. +* Added animation shorthand alias. +* Added user-select alias. + +## 1.3 (2011-10-20) + +* Added the public function `CssCrush::string` for processing raw strings of CSS. +* Added color functions. +* Added aliases for IE10. + +## 1.2.0 (2011-9-8) + +* File importer rewritten. + +## 1.1.0 (2011-9-2) + +* Added support for global variables. +* Added support for variable interpolation within string literals. +* Added `CssCrush::tag` method for outputting an html link tag instead of returning a filename. +* Added values aliases, dynamic 'runtime' variables. +* Added RGBA macro. +* Added IE clip macro. +* Added data uri function. +* Minor correction to WAMP support. +* Minor fix to rule API. + +## 1.0.0 (2011-7-14) + +* Major refactoring. +* Custom functions. +* Optional boilerplate. +* Double colon syntax shim. +* Resolved document root issues. +* Minification improvements. + +## 0.9.0 (2010-9-20) + +* Initial release. diff --git a/CssCrush.boilerplate b/CssCrush.boilerplate deleted file mode 100644 index c0ed732..0000000 --- a/CssCrush.boilerplate +++ /dev/null @@ -1,2 +0,0 @@ -CSS Crush(ed) on {{datetime}} -http://github.com/peteboere/css-crush \ No newline at end of file diff --git a/CssCrush.php b/CssCrush.php index ad15d4c..94e1497 100644 --- a/CssCrush.php +++ b/CssCrush.php @@ -1,39 +1,19 @@ - * - * - * - */ - -require_once 'lib/Util.php'; -require_once 'lib/Core.php'; -CssCrush::init( dirname( __FILE__ ) ); - -require_once 'lib/Rule.php'; - -require_once 'lib/Function.php'; -CssCrush_Function::init(); - -require_once 'lib/Importer.php'; -require_once 'lib/Color.php'; -require_once 'lib/Hook.php'; + * + * Bootstrap file with autoloader. + * + */ +spl_autoload_register(function ($class) { + if (stripos($class, 'csscrush') !== 0) { + return; + } + $class = str_ireplace('csscrush', 'CssCrush', $class); + $subpath = implode('/', array_map('ucfirst', explode('\\', $class))); + require_once __DIR__ . "/lib/$subpath.php"; +}); +require_once 'lib/functions.php'; diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..effaf8f --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,19 @@ +Copyright (c) 2010-2015 Pete Boere + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Plugins.ini b/Plugins.ini deleted file mode 100644 index 35fa4af..0000000 --- a/Plugins.ini +++ /dev/null @@ -1,33 +0,0 @@ -;---------------------------------------------------------------- -; Enable/disable plugins by commenting them (with semicolons) -;---------------------------------------------------------------- - -; min-height shim for IE < 7 -; plugins[] = ie-min-height.php - -; inline-block shim for IE < 8 -plugins[] = ie-inline-block.php - -; clip property shim for IE < 8 -; plugins[] = ie-clip.php - -; IE filter shorthand -; plugins[] = ie-filter.php - -; Opacity for IE < 9 (uses filter) -; plugins[] = ie-opacity.php - -; Opaque fallback colors for clients that don't support RGBA -; plugins[] = rgba-fallback.php - -; HSL shim - converts HSL values to hex codes -; plugins[] = hsl-to-hex.php - -; Compiles pseudo element double colon syntax to single colon for backwards compatibility -plugins[] = double-colon.php - -; Non-standard composite pseudo classes -plugins[] = hocus-pocus.php - -; CSS3 'initial' keyword shim -plugins[] = initial.php \ No newline at end of file diff --git a/Prepend.css b/Prepend.css deleted file mode 100644 index 2d2e2a4..0000000 --- a/Prepend.css +++ /dev/null @@ -1,39 +0,0 @@ -/*$! - -Prepend.css contains library variables by default, but it could also contain reset styles such as reset.css or normalize.css that you would need prepended to every css file. - -*/ - -@define { - - /*------------------------ - Font stacks - ------------------------*/ - - /* Serif */ - georgia: Georgia, Times, "Times New Roman", serif; - times: Times, "Times New Roman", serif; - palatino: Palatino, 'Palatino Linotype', "Hoefler Text", serif; - serif: $( times ); - - /* Sans-serif */ - helvetica: "Helvetica Neue", Helvetica, Arial, sans-serif; - arial: "Arial Unicode MS", Arial, Helvetica, sans-serif; - verdana: Verdana, Tahoma, Arial, sans-serif; - lucida: "Lucida Sans Unicode", "Lucida Sans", "Lucida Grande", Verdana, sans-serif; - sans-serif: $( arial ); - - /* Monospace */ - courier: "Courier New", Courier, mono; - consolas: Consolas, "Lucida Console", Monaco, "Courier New", Courier, mono; - monaco: Monaco, "Courier New", Courier, mono; - mono: $( courier ); - - /* Unicode */ - unicode: "Arial Unicode MS", Arial, "Microsoft Sans Serif", "Lucida Grande", sans-serif; - -} - - - - diff --git a/README.md b/README.md index aebd534..7daddbc 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,97 @@ -CSS Crush -===== +![CI](https://github.com/peteboere/css-crush/actions/workflows/php.yml/badge.svg) -CSS Crush is an extensible PHP based CSS preprocessor that aims to alleviate many of the hacks and workarounds necessary in modern CSS development. +Logo +A CSS preprocessor designed to enable a modern and uncluttered CSS workflow. -Overview -=================================== +* Automatic vendor prefixing +* Variables +* Import inlining +* Nesting +* Functions (color manipulation, math, data-uris etc.) +* Rule inheritance (@extends) +* Mixins +* Minification +* Lightweight plugin system +* Source maps -http://the-echoplex.net/csscrush +See the [docs](http://the-echoplex.net/csscrush) for full details. +******************************** -Quick start -=================================== +## Setup (PHP) - - - +If you're using [Composer](http://getcomposer.org) you can use Crush in your project with the following line in your terminal: +```shell +composer require css-crush/css-crush:dev-master +``` -Submitting bugs -=================================== +If you're not using Composer yet just download the library into a convenient location and require the bootstrap file: -If you think you've found a bug, please visit the Issue tracker — https://github.com/peteboere/css-crush/issues — and create an issue explaining the problem and expected result. +```php + +``` +## Basic usage (PHP) -Submitting patches -=================================== +```php + +``` + +Compiles the CSS file and outputs the following link tag: + +```html + +``` + +There are several other [functions](http://the-echoplex.net/csscrush#api) for working with files and strings of CSS: + +* `csscrush_file($file, $options)` - Returns a URL of the compiled file. +* `csscrush_string($css, $options)` - Compiles a raw string of css and returns the resulting css. +* `csscrush_inline($file, $options, $tag_attributes)` - Returns compiled css in an inline style tag. + +There are a number of [options](http://the-echoplex.net/csscrush#api--options) available for tailoring the output, and a collection of bundled [plugins](http://the-echoplex.net/csscrush#plugins) that cover many workflow issues in contemporary CSS development. + +******************************** + +## Setup (JS) + +```shell +npm install csscrush +``` + +## Basic usage (JS) + +```js +// All methods can take the standard options (camelCase) as the second argument. +const csscrush = require('csscrush'); + +// Compile. Returns promise. +csscrush.file('./styles.css', {sourceMap: true}); + +// Compile string of CSS. Returns promise. +csscrush.string('* {box-sizing: border-box;}'); + +// Compile and watch file. Returns event emitter (triggers 'data' on compile). +csscrush.watch('./styles.css'); +``` + +******************************** + +## Contributing + +If you think you've found a bug please create an [issue](https://github.com/peteboere/css-crush/issues) explaining the problem and expected result. + +Likewise, if you'd like to request a feature please create an [issue](https://github.com/peteboere/css-crush/issues) with some explanation of the requested feature and use-cases. + +[Pull requests](https://help.github.com/articles/using-pull-requests) are welcome, though please keep coding style consistent with the project (which is based on [PSR-2](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)). + + +## Licence + +MIT diff --git a/aliases.ini b/aliases.ini new file mode 100644 index 0000000..cf7761f --- /dev/null +++ b/aliases.ini @@ -0,0 +1,102 @@ +;---------------------------------------------------------------- +; +; Sources: +; http://developer.mozilla.org/en-US/docs/CSS/CSS_Reference +; http://caniuse.com/#cats=CSS +; +;---------------------------------------------------------------- +; Property aliases. + +[properties] + + ; Animations. + animation[] = -webkit-animation + animation-delay[] = -webkit-animation-delay + animation-direction[] = -webkit-animation-direction + animation-duration[] = -webkit-animation-duration + animation-fill-mode[] = -webkit-animation-fill-mode + animation-iteration-count[] = -webkit-animation-iteration-count + animation-name[] = -webkit-animation-name + animation-play-state[] = -webkit-animation-play-state + animation-timing-function[] = -webkit-animation-timing-function + + ; Backdrop filter. + backdrop-filter[] = -webkit-backdrop-filter + + ; Backface visibility. + backface-visibility[] = -webkit-backface-visibility + + ; Border-image. + border-image[] = -webkit-border-image + + ; Box decoration. + box-decoration-break[] = -webkit-box-decoration-break + + ; Filter. + filter[] = -webkit-filter + + ; Hyphens. + hyphens[] = -webkit-hyphens + + ; Tab size. + tab-size[] = -moz-tab-size + tab-size[] = -o-tab-size + + ; User select (non standard). + user-select[] = -webkit-user-select + user-select[] = -moz-user-select + + +;---------------------------------------------------------------- +; Declaration aliases. + +[declarations] + + ; Experimental width values. + width:max-content[] = width:intrinsic + width:max-content[] = width:-webkit-max-content + width:max-content[] = width:-moz-max-content + width:min-content[] = width:-webkit-min-content + width:min-content[] = width:-moz-min-content + width:available[] = width:-webkit-available + width:available[] = width:-moz-available + width:fit-content[] = width:-webkit-fit-content + width:fit-content[] = width:-moz-fit-content + + max-width:max-content[] = max-width:intrinsic + max-width:max-content[] = max-width:-webkit-max-content + max-width:max-content[] = max-width:-moz-max-content + max-width:min-content[] = max-width:-webkit-min-content + max-width:min-content[] = max-width:-moz-min-content + max-width:available[] = max-width:-webkit-available + max-width:available[] = max-width:-moz-available + max-width:fit-content[] = max-width:-webkit-fit-content + max-width:fit-content[] = max-width:-moz-fit-content + + min-width:max-content[] = min-width:intrinsic + min-width:max-content[] = min-width:-webkit-max-content + min-width:max-content[] = min-width:-moz-max-content + min-width:min-content[] = min-width:-webkit-min-content + min-width:min-content[] = min-width:-moz-min-content + min-width:available[] = min-width:-webkit-available + min-width:available[] = min-width:-moz-available + min-width:fit-content[] = min-width:-webkit-fit-content + min-width:fit-content[] = min-width:-moz-fit-content + + ; Appearance (non-standard). + appearance:none[] = -webkit-appearance:none + appearance:none[] = -moz-appearance:none + + position:sticky[] = position:-webkit-sticky + + +;---------------------------------------------------------------- +; Function aliases. + +[functions] + + +;---------------------------------------------------------------- +; @rule aliases. + +[at-rules] diff --git a/bin/csscrush b/bin/csscrush new file mode 100755 index 0000000..559fb84 --- /dev/null +++ b/bin/csscrush @@ -0,0 +1,8 @@ +#!/usr/bin/env php +getMessage(), ['type'=>'error'])); + + exit($ex->getCode()); +} ################################################################## -## Help page +## Information options. -// $command = $argv[0] == 'csscrush' ? 'csscrush' : 'php path/to/CssCrush/cli.php'; -$command = 'csscrush'; +if ($args->version) { -$help = <<help) { -options: - -f, --file: - The input file, if omitted takes input from stdin + stdout(manpage()); - -o, --output: - The output file, if omitted prints to stdout + exit(STATUS_OK); +} - -p, --pretty: - Formatted, unminified output - -b, --boilerplate: - Whether or not to output a boilerplate +################################################################## +## Resolve input. - -h, --help: - Display this help mesasge +$input = null; - --variables: - Map of variable names in an http query string format +if ($args->input_file) { - --vendor-target: - Set to 'all' for all vendor prefixes (default) - Set to 'none' for no vendor prefixes - Set to a specific vendor prefix + $input = file_get_contents($args->input_file); +} +elseif ($stdin = get_stdin_contents()) { -examples: - $command -f=styles.css --pretty --vendor-target=webkit + $input = $stdin; +} +else { + stdout(manpage()); - # Piping on unix based terminals - cat 'styles.css' | $command --boilerplate + exit(STATUS_OK); +} -TPL; +if ($args->watch && ! $args->input_file) { + stderr(message('Watch mode requires an input file.', ['type'=>'error'])); -if ( $help_flag ) { - fwrite( $stdout, $help ); - exit( 1 ); + exit(STATUS_ERROR); } ################################################################## -## Input +## Resolve process options. -$input = null; +$configFile = 'crushfile.php'; +if (file_exists($configFile)) { + $options = CssCrush\Util::readConfigFile($configFile); +} +else { + $options = []; +} + +if ($args->pretty) { + $options['minify'] = false; +} -if ( $input_file ) { - if ( ! file_exists( $input_file ) ) { - fwrite( $stdout, "can't find input file\n\n" ); - exit( 0 ); - } - $input = file_get_contents( $input_file ); +foreach (['boilerplate', 'formatter', 'newlines', + 'stat_dump', 'source_map', 'import_path'] as $option) { + if ($args->$option || $args->$option === false) { + $options[$option] = $args->$option; + } } -elseif ( $stdin_contents ) { - $input = $stdin_contents; + +if ($args->enable_plugins) { + $options['plugins'] = parse_list($args->enable_plugins); +} + +if ($args->vendor_target) { + $options['vendor_target'] = parse_list($args->vendor_target); +} + +if ($args->vars) { + parse_str($args->vars, $in_vars); + $options['vars'] = $in_vars; +} + +if ($args->output_file) { + $options['output_dir'] = dirname($args->output_file); + $options['output_file'] = basename($args->output_file); +} + +$options += [ + 'doc_root' => getcwd(), + 'context' => $args->context, +]; + + +################################################################## +## Output. + +error_reporting(0); + +if ($args->watch) { + + csscrush_set('config', ['io' => 'CssCrush\IO\Watch']); + + stdout('CONTROL-C to quit.'); + + $outstandingErrors = false; + + while (true) { + + if (! file_exists($args->input_file)) { + stderr(message(['Input file was not found'], ['type'=>'error'])); + exit(STATUS_ERROR); + } + + csscrush_file($args->input_file, $options); + $stats = csscrush_stat(); + + $changed = $stats['compile_time'] && ! $stats['errors']; + $errors = $stats['errors']; + $warnings = $stats['warnings']; + $showErrors = $errors && (! $outstandingErrors || ($outstandingErrors != $errors)); + + if ($errors) { + if ($showErrors) { + $outstandingErrors = $errors; + stderr(message($errors, ['type'=>'error'])); + } + } + elseif ($changed) { + $outstandingErrors = false; + stderr(message(fmt_fileinfo($stats, 'output'), ['type'=>'write'])); + } + + if (($showErrors || $changed) && $warnings) { + stderr(message($warnings, ['type'=>'warning'])); + } + + if ($changed && $args->stats) { + stderr(message($stats, ['type'=>'stats'])); + } + + sleep(1); + } } else { - fwrite( $stdout, $help ); - exit( 1 ); + + $stdOutput = null; + + if ($args->input_file && isset($options['output_dir'])) { + $options['cache'] = false; + csscrush_file($args->input_file, $options); + } + else { + $stdOutput = csscrush_string($input, $options); + } + + $stats = csscrush_stat(); + $errors = $stats['errors']; + $warnings = $stats['warnings']; + + if ($errors) { + stderr(message($errors, ['type'=>'error'])); + + exit(STATUS_ERROR); + } + elseif ($args->input_file && ! empty($stats['output_filename'])) { + stderr(message(fmt_fileinfo($stats, 'output'), ['type'=>'write'])); + } + + if ($warnings) { + stderr(message($warnings, ['type'=>'warning'])); + } + + if ($args->stats) { + stderr(message($stats, ['type'=>'stats'])); + } + + if ($stdOutput) { + stdout($stdOutput); + } + + exit(STATUS_OK); } ################################################################## -## Processing +## Helpers. + +function stderr($lines, $closing_newline = true) { -$process_opts = array(); -if ( $vendor_target ) { - $process_opts[ 'vendor_target' ] = $vendor_target; + $out = implode(PHP_EOL, (array) $lines) . ($closing_newline ? PHP_EOL : ''); + fwrite(defined('TESTMODE') && TESTMODE ? STDOUT : STDERR, $out); } -if ( $variables ) { - parse_str( $variables, $in_vars ); - $process_opts[ 'vars' ] = $in_vars; + +function stdout($lines, $closing_newline = true) { + + $out = implode(PHP_EOL, (array) $lines) . ($closing_newline ? PHP_EOL : ''); + fwrite(STDOUT, $out); } -$process_opts[ 'boilerplate' ] = $boilerplate ? true : false; -$process_opts[ 'debug' ] = $pretty ? true : false; -$process_opts[ 'rewrite_import_urls' ] = true; -$import_context = $input_file ? dirname( realpath( $input_file ) ) : null; +function get_stdin_contents() { + + stream_set_blocking(STDIN, 0); + $contents = stream_get_contents(STDIN); + stream_set_blocking(STDIN, 1); -// If there is an import context set it to the document root -if ( $import_context ) { - $old_doc_root = csscrush::$config->docRoot; - csscrush::$config->docRoot = $import_context; - $process_opts[ 'import_context' ] = $import_context; + return $contents; } -// Process the stream -$output = csscrush::string( $input, $process_opts ); +function parse_list(array $option) { + + $out = []; + foreach ($option as $arg) { + if (is_string($arg)) { + foreach (preg_split('~\s*,\s*~', $arg) as $item) { + $out[] = $item; + } + } + else { + $out[] = $arg; + } + } + return $out; +} -// Reset the document root after processing -if ( $import_context ) { - csscrush::$config->docRoot = $old_doc_root; +function message($messages, $options = []) { + + $defaults = [ + 'color' => 'b', + 'label' => null, + 'indent' => false, + 'format_label' => false, + ]; + $preset = ! empty($options['type']) ? $options['type'] : null; + switch ($preset) { + case 'error': + $defaults['color'] = 'r'; + $defaults['label'] = 'ERROR'; + break; + case 'warning': + $defaults['color'] = 'y'; + $defaults['label'] = 'WARNING'; + break; + case 'write': + $defaults['color'] = 'g'; + $defaults['label'] = 'WRITE'; + break; + case 'stats': + // Making stats concise and readable. + $messages['input_file'] = $messages['input_path']; + $messages['compile_time'] = round($messages['compile_time'], 5) . ' seconds'; + foreach (['input_filename', 'input_path', 'output_filename', + 'output_path', 'vars', 'errors', 'warnings'] as $key) { + unset($messages[$key]); + } + ksort($messages); + $defaults['indent'] = true; + $defaults['format_label'] = true; + break; + } + extract($options + $defaults); + + $out = []; + foreach ((array) $messages as $_label => $value) { + $_label = $label ?: $_label; + if ($format_label) { + $_label = ucfirst(str_replace('_', ' ', $_label)); + } + $prefix = $indent ? '└── ' : ''; + $colorUp = strtoupper($color); + if (is_scalar($value)) { + $out[] = colorize("<$color>$prefix<$colorUp>$_label:<$color> $value"); + } + } + return implode(PHP_EOL, $out); } +function fmt_fileinfo($stats, $type) { + $time = round($stats['compile_time'], 3); + return $stats[$type . '_path'] . " ({$time}s)"; +} -################################################################## -## Output +function pick(array &$arr) { -if ( $output_file ) { - if ( ! @file_put_contents( $output_file, $output ) ) { - fwrite( $stdout, "Could not write to path '$output_file'\n" ); - if ( strpos( $output_file, '~' ) === 0 ) { - fwrite( $stdout, "No tilde expansion\n" ); - } - exit( 0 ); - } + $args = func_get_args(); + array_shift($args); + + foreach ($args as $key) { + if (isset($arr[$key])) { + // Optional values return false but we want true if argument is present. + return is_bool($arr[$key]) ? true : $arr[$key]; + } + } + return null; } -else { - $output .= "\n"; - fwrite( $stdout, $output ); + +function colorize($str) { + + static $color_support; + static $tags = [ + '' => "\033[0;30m", + '' => "\033[0;31m", + '' => "\033[0;32m", + '' => "\033[0;33m", + '' => "\033[0;34m", + '' => "\033[0;35m", + '' => "\033[0;36m", + '' => "\033[0;37m", + + '' => "\033[1;30m", + '' => "\033[1;31m", + '' => "\033[1;32m", + '' => "\033[1;33m", + '' => "\033[1;34m", + '' => "\033[1;35m", + '' => "\033[1;36m", + '' => "\033[1;37m", + + '' => "\033[m", + ]; + + if (! isset($color_support)) { + $color_support = defined('TESTMODE') && TESTMODE ? false : true; + if (DIRECTORY_SEPARATOR == '\\') { + $color_support = false !== getenv('ANSICON') || 'ON' === getenv('ConEmuANSI'); + } + } + + $find = array_keys($tags); + $replace = $color_support ? array_values($tags) : ''; + + return str_replace($find, $replace, $str); } -exit( 1 ); +function get_trailing_io_args($required_value_opts) { + + $trailing_input_file = null; + $trailing_output_file = null; + + // Get raw script args, shift off calling scriptname and reduce to last three. + $trailing_args = $GLOBALS['argv']; + array_shift($trailing_args); + $trailing_args = array_slice($trailing_args, -3); + + // Create patterns for detecting options. + $required_values = implode('|', $required_value_opts); + $value_opt_patt = "~^-{1,2}($required_values)$~"; + $other_opt_patt = "~^-{1,2}([a-z0-9\-]+)?(=|$)~ix"; + + // Step through the args. + $filtered = []; + for ($i = 0; $i < count($trailing_args); $i++) { + + $current = $trailing_args[$i]; + + // If tests as a required value option, reset and skip next. + if (preg_match($value_opt_patt, $current)) { + $filtered = []; + $i++; + } + // If it looks like any other kind of flag, or optional value option, reset. + elseif (preg_match($other_opt_patt, $current)) { + $filtered = []; + } + else { + $filtered[] = $current; + } + } + + // We're only interested in the last two values. + $filtered = array_slice($filtered, -2); + + switch (count($filtered)) { + case 1: + $trailing_input_file = $filtered[0]; + break; + case 2: + $trailing_input_file = $filtered[0]; + $trailing_output_file = $filtered[1]; + break; + } + + return [$trailing_input_file, $trailing_output_file]; +} +function parse_args() { + + $required_value_opts = [ + 'i|input|f|file', // Input file. Defaults to STDIN. + 'o|output', // Output file. Defaults to STDOUT. + 'E|enable|plugins', + 'D|disable', + 'vars|variables', + 'formatter', + 'vendor-target', + 'context', + 'import-path', + 'newlines', + ]; + + $optional_value_opts = [ + 'b|boilerplate', + 'stat-dump', + ]; + + $flag_opts = [ + 'p|pretty', + 'w|watch', + 'help', + 'version', + 'source-map', + 'stats', + 'test', + ]; + + // Create option strings for getopt(). + $short_opts = []; + $long_opts = []; + $join_opts = function ($opts_list, $modifier) use (&$short_opts, &$long_opts) { + foreach ($opts_list as $opt) { + foreach (explode('|', $opt) as $arg) { + if (strlen($arg) === 1) { + $short_opts[] = "$arg$modifier"; + } + else { + $long_opts[] = "$arg$modifier"; + } + } + } + }; + $join_opts($required_value_opts, ':'); + $join_opts($optional_value_opts, '::'); + $join_opts($flag_opts, ''); + + $opts = getopt(implode($short_opts), $long_opts); + + $args = new stdClass(); + + // Information options. + $args->help = isset($opts['h']) ?: isset($opts['help']); + $args->version = isset($opts['version']); + + // File arguments. + $args->input_file = pick($opts, 'i', 'input', 'f', 'file'); + $args->output_file = pick($opts, 'o', 'output'); + $args->context = pick($opts, 'context'); + + // Flags. + $args->pretty = isset($opts['p']) ?: isset($opts['pretty']); + $args->watch = isset($opts['w']) ?: isset($opts['watch']); + $args->source_map = isset($opts['source-map']); + $args->stats = pick($opts, 'stats'); + define('TESTMODE', isset($opts['test'])); + + // Arguments that optionally accept a single value. + $args->boilerplate = pick($opts, 'b', 'boilerplate'); + $args->stat_dump = pick($opts, 'stat-dump'); + + // Arguments that require a single value. + $args->formatter = pick($opts, 'formatter'); + $args->vars = pick($opts, 'vars', 'variables'); + $args->newlines = pick($opts, 'newlines'); + + // Arguments that require a value but accept multiple values. + $args->enable_plugins = pick($opts, 'E', 'enable', 'plugins'); + $args->vendor_target = pick($opts, 'vendor-target'); + $args->import_path = pick($opts, 'import-path'); + + // Run multiple value arguments through array cast. + foreach (['enable_plugins', 'vendor_target'] as $arg) { + if ($args->$arg) { + $args->$arg = (array) $args->$arg; + } + } + + // Detect trailing IO files from raw script arguments. + list($trailing_input_file, $trailing_output_file) = get_trailing_io_args($required_value_opts); + + // If detected apply, not overriding explicit IO file options. + if (! $args->input_file && $trailing_input_file) { + $args->input_file = $trailing_input_file; + } + if (! $args->output_file && $trailing_output_file) { + $args->output_file = $trailing_output_file; + } + + if ($args->input_file) { + $inputFile = $args->input_file; + if (! ($args->input_file = realpath($args->input_file))) { + throw new Exception("Input file '$inputFile' does not exist.", STATUS_ERROR); + } + } + + if ($args->output_file) { + $outDir = dirname($args->output_file); + if (! realpath($outDir) && ! @mkdir($outDir, 0755, true)) { + throw new Exception('Output directory does not exist and could not be created.', STATUS_ERROR); + } + $args->output_file = realpath($outDir) . '/' . basename($args->output_file); + } + + if ($args->context) { + if (! ($args->context = realpath($args->context))) { + throw new Exception('Context path does not exist.', STATUS_ERROR); + } + } + else { + $args->context = $args->input_file ? dirname($args->input_file) : getcwd(); + } + + if (is_string($args->boilerplate)) { + if ($args->boilerplate === 'false') { + $args->boilerplate = false; + } + else if (! ($args->boilerplate = realpath($args->boilerplate))) { + throw new Exception('Boilerplate file does not exist.', STATUS_ERROR); + } + } + + return $args; +} +function manpage() { + $manpage = <<USAGE: + csscrush [OPTIONS] [input-file] [output-file] +OPTIONS: + -i, --input + Input file. If omitted takes input from STDIN. + -o, --output + Output file. If omitted prints to STDOUT. + -p, --pretty + Formatted, un-minified output. + + -w, --watch + Watch input file for changes. + Writes to file specified with -o option or to the input file + directory with a '.crush.css' file extension. + + -E, --plugins + List of plugins (comma separated) to enable. + + --boilerplate + Whether or not to output a boilerplate. Optionally accepts filepath + to a custom boilerplate template. + + --context + Filepath context for resolving relative import URLs. + Only meaningful when taking raw input from STDIN. + + --import-path + Comma separated list of additional paths to search when resolving + relative import URLs. + + --formatter + Possible values: + 'block' (default) + Rules are block formatted. + 'single-line' + Rules are printed in single lines. + 'padded' + Rules are printed in single lines with right padded selectors. + + --help + Display this help message. + + --newlines + Force newline style on output css. Defaults to the current platform + newline. Possible values: 'windows' (or 'win'), 'unix', 'use-platform'. + + --source-map + Create a source map file (compliant with the Source Map v3 proposal). + + --stats + Display post-compile stats. + + --vars + Map of variable names in an http query string format. + + --vendor-target + Possible values: + 'all' + For all vendor prefixes (default). + 'none' + For no vendor prefixing. + 'moz', 'webkit', 'ms' etc. + Limit to a specific vendor prefix (or comma separated list). + + --version + Display version number. + +EXAMPLES: + # Restrict vendor prefixing. + csscrush --pretty --vendor-target webkit -i styles.css + + # Piped input. + cat styles.css | csscrush --vars 'foo=black&bar=white' > alt-styles.css + + # Linting. + csscrush --pretty -E property-sorter -i styles.css -o linted.css + + # Watch mode. + csscrush --watch -i styles.css -o compiled/styles.css + + # Using custom boilerplate template. + csscrush --boilerplate=css/boilerplate.txt css/styles.css + +TPL; + + return colorize($manpage); +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..a783e68 --- /dev/null +++ b/composer.json @@ -0,0 +1,38 @@ +{ + "name": "css-crush/css-crush", + "type": "library", + "description": "CSS preprocessor", + "keywords": ["css", "preprocessor"], + "homepage": "http://the-echoplex.net/csscrush", + "license": "MIT", + "scripts": { + "test": "vendor/bin/phpunit tests", + "lint": "vendor/bin/phpstan" + }, + "authors": [ + { + "name": "Pete Boere", + "email": "pete@the-echoplex.net" + }, + { + "name": "GitHub contributors", + "homepage": "https://github.com/peteboere/css-crush/contributors" + } + ], + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "9.6.23", + "psr/log": "1.0.*@dev", + "phpstan/phpstan": "^1.10", + "twig/twig": "3.11.3" + }, + "bin": [ + "bin/csscrush" + ], + "autoload": { + "psr-0": { "CssCrush": "lib/" }, + "files": [ "lib/functions.php" ] + } +} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..9c284db --- /dev/null +++ b/docs/README.md @@ -0,0 +1,3 @@ +# CSS-Crush Documentation + +Rendered online at http://the-echoplex.net/csscrush diff --git a/docs/api/functions.md b/docs/api/functions.md new file mode 100644 index 0000000..639f9fb --- /dev/null +++ b/docs/api/functions.md @@ -0,0 +1,84 @@ + + +## csscrush_file() + +Process CSS file and return the compiled file URL. + +csscrush_file( string $file [, array [$options](#api--options) ] ) + + +*************** + +## csscrush_tag() + +Process CSS file and return an html `link` tag with populated href. + +csscrush_tag( string $file [, array [$options](#api--options) [, array $tag\_attributes ]] ) + + +*************** + +## csscrush_inline() + +Process CSS file and return CSS as text wrapped in html `style` tags. + +csscrush_inline( string $file [, array [$options](#api--options) [, array $tag\_attributes ]] ) + + +*************** + +## csscrush_string() + +Compile a raw string of CSS string and return it. + +csscrush_string( string $string [, array [$options](#api--options) ] ) + + +*************** + +## csscrush_get() + +Retrieve a config setting or option default. + +`csscrush_get( string $object_name, string $property )` + +### Parameters + + * `$object_name` Name of object you want to inspect: 'config' or 'options'. + * `$property` + + +*************** + +## csscrush_set() + +Set a config setting or option default. + +`csscrush_set( string $object_name, mixed $settings )` + +### Parameters + + * `$object_name` Name of object you want to modify: 'config' or 'options'. + * `$settings` Associative array of keys and values to set, or callable which argument is the object specified in `$object_name`. + + +*************** + +## csscrush_plugin() + +Register a plugin. + +`csscrush_plugin( string $name, callable $callback )` + + +*************** + +## csscrush_stat() + +Get compilation stats from the most recent compiled file. + +`csscrush_stat()` diff --git a/docs/api/options.md b/docs/api/options.md new file mode 100644 index 0000000..1aa3fb2 --- /dev/null +++ b/docs/api/options.md @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Option + Values (default in bold) + Description +
minify + true | false | Array + Enable or disable minification. Optionally specify an array of advanced minification parameters. Currently the only advanced option is 'colors', which will compress all color values in any notation. +
formatter + block | single-line | padded + Set the formatting mode. Overrides minify option if both are set. +
newlines + use-platform | windows/win | unix + Set the output style of newlines +
boilerplate + true | false | Path + Prepend a boilerplate to the output file +
versioning + true | false + Append a timestamped querystring to the output filename +
vars + Array + An associative array of CSS variables to be applied at runtime. These will override variables declared globally or in the CSS. +
cache + true | false + Turn caching on or off. +
output_dir + Path + Specify an output directory for compiled files. Defaults to the same directory as the host file. +
output_file + Output filename + Specify an output filename (suffix is added). +
asset_dir + Path + Directory for SVG and image files generated by plugins (defaults to the main file output directory). +
stat_dump + false | true | Path + Save compile stats and variables to a file in json format. +
vendor_target + "all" | "moz", "webkit", ... | Array +Limit aliasing to a specific vendor, or an array of vendors. +
rewrite_import_urls + true | false | "absolute" + Rewrite relative URLs inside inlined imported files. +
import_paths + Array + Additional paths to search when resolving relative import URLs. +
plugins + Array + An array of plugin names to enable. +
source_map + true | false + Output a source map (compliant with the Source Map v3 proposal). +
context + Path + Context for importing resources from relative urls (Only applies to `csscrush_string()` and command line utility). +
doc_root + Path + Specify an alternative server document root for situations where the CSS is being served behind an alias or url rewritten path. +
diff --git a/docs/core/abstract.md b/docs/core/abstract.md new file mode 100644 index 0000000..4936aa8 --- /dev/null +++ b/docs/core/abstract.md @@ -0,0 +1,44 @@ + + +Abstract rules are generic rules that can be [extended](#core--inheritance) with the `@extend` directive or mixed in (without arguments) like regular [mixins](#core--mixins) with the `@include` directive. + +```crush +@abstract ellipsis { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +@abstract heading { + font: bold 1rem serif; + letter-spacing: .1em; +} + +.foo { + @extend ellipsis; + display: block; +} +.bar { + @extend ellipsis; + @include heading; +} +``` + +```css +.foo, +.bar { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.foo { + display: block; +} +.bar { + font: bold 1rem serif; + letter-spacing: .1em; +} +``` diff --git a/docs/core/auto-prefixing.md b/docs/core/auto-prefixing.md new file mode 100644 index 0000000..02c67b6 --- /dev/null +++ b/docs/core/auto-prefixing.md @@ -0,0 +1,39 @@ + + +Vendor prefixes for properties, functions, @-rules and declarations are **automatically generated** – based on [trusted](http://caniuse.com) [sources](http://developer.mozilla.org/en-US/docs/CSS/CSS_Reference) – so you can maintain cross-browser support while keeping your source code clean and easy to maintain. + + +```crush +.foo { + background: linear-gradient(to right, red, white); +} +``` + +```css +.foo { + background: -webkit-linear-gradient(to right, red, white); + background: linear-gradient(to right, red, white); +} +``` + + +```crush +@keyframes bounce { + 50% { transform: scale(1.4); } +} +``` + +```css +@-webkit-keyframes bounce { + 50% {-webkit-transform: scale(1.4); + transform: scale(1.4);} +} +@keyframes bounce { + 50% {-webkit-transform: scale(1.4); + transform: scale(1.4);} +} +``` diff --git a/docs/core/direct-import.md b/docs/core/direct-import.md new file mode 100644 index 0000000..fea9d69 --- /dev/null +++ b/docs/core/direct-import.md @@ -0,0 +1,25 @@ + + +Files referenced with the `@import` directive are inlined directly to save on http requests. Relative URL paths in the CSS are also updated if necessary. + +If you specify a media designation following the import URL — as per the CSS standard — the imported file content is wrapped in a `@media` block. + + +```crush +/* Standard CSS @import statements */ +@import "http://github.com/print.css" print; +@import url(http://github.com/%22small-screen.css%22) screen and ( max-width: 500px ); +``` + +```css +@media print { + /* Contents of print.css */ +} +@media screen and ( max-width: 500px ) { + /* Contents of small-screen.css */ +} +``` diff --git a/docs/core/fragments.md b/docs/core/fragments.md new file mode 100644 index 0000000..2d3d693 --- /dev/null +++ b/docs/core/fragments.md @@ -0,0 +1,25 @@ + + +Fragments – defined and invoked with the @fragment directive – work in a similar way to [mixins](#core--mixins), except that they work at block level: + +```crush +@fragment input-placeholder { + #(1)::-webkit-input-placeholder { color: #(0); } + #(1):-moz-placeholder { color: #(0); } + #(1)::placeholder { color: #(0); } + #(1).placeholder-state { color: #(0); } +} + +@fragment input-placeholder(#777, textarea); +``` + +```css +textarea::-webkit-input-placeholder { color: #777; } +textarea:-moz-placeholder { color: #777; } +textarea::placeholder { color: #777; } +textarea.placeholder-state { color: #777; } +``` diff --git a/docs/core/functions/a-adjust.md b/docs/core/functions/a-adjust.md new file mode 100644 index 0000000..59d2c93 --- /dev/null +++ b/docs/core/functions/a-adjust.md @@ -0,0 +1,26 @@ + + +Manipulate the opacity (alpha channel) of a color value. + +a-adjust( *color*, *offset* ) + +## Parameters + +* *`color`* Any valid CSS color value +* *`offset`* The percentage to offset the color opacity + +## Returns + +The modified color value + + +## Examples + +```css +/* Reduce color opacity by 10% */ +color: a-adjust( rgb(50,50,0) -10 ); +``` diff --git a/docs/core/functions/data-uri.md b/docs/core/functions/data-uri.md new file mode 100644 index 0000000..32b0459 --- /dev/null +++ b/docs/core/functions/data-uri.md @@ -0,0 +1,33 @@ + + +Create a data-uri. + +data-uri( *url* ) + +## Parameters + +* *`url`* URL of an asset + +`url` cannot be external, and must not be written with an http protocol prefix. + +The following file extensions are supported: jpg, jpeg, gif, png, svg, svgz, ttf, woff + + +## Returns + +The created data-uri as a string inside a CSS url(). + + +## Examples + +```crush +background: silver data-uri(../images/stripe.png); +``` + +```css +background: silver url(data:); +``` diff --git a/docs/core/functions/h-adjust.md b/docs/core/functions/h-adjust.md new file mode 100644 index 0000000..98605c9 --- /dev/null +++ b/docs/core/functions/h-adjust.md @@ -0,0 +1,24 @@ + + +Adjust the hue of a color value. + +h-adjust( *color*, *offset* ) + +## Parameters + +* *`color`* Any valid CSS color value +* *`offset`* The percentage to offset the color hue (percent mark optional) + +## Returns + +The modified color value. + +## Examples + +```css +color: h-adjust( deepskyblue -10 ); +``` diff --git a/docs/core/functions/hsl-adjust.md b/docs/core/functions/hsl-adjust.md new file mode 100644 index 0000000..bdee0fd --- /dev/null +++ b/docs/core/functions/hsl-adjust.md @@ -0,0 +1,27 @@ + + +Manipulate the hue, saturation and lightness of a color value + +hsl-adjust( *color*, *hue-offset*, *saturation-offset*, *lightness-offset* ) + +## Parameters + +* *`color`* Any valid CSS color value +* *`hue-offset`* The percentage to offset the color hue +* *`saturation-offset`* The percentage to offset the color saturation +* *`lightness-offset`* The percentage to offset the color lightness + +## Returns + +The modified color value + +## Examples + +```css +/* Lighten and increase saturation */ +color: hsl-adjust( red 0 5 5 ); +``` diff --git a/docs/core/functions/hsla-adjust.md b/docs/core/functions/hsla-adjust.md new file mode 100644 index 0000000..cc05b4b --- /dev/null +++ b/docs/core/functions/hsla-adjust.md @@ -0,0 +1,27 @@ + + +Manipulate the hue, saturation, lightness and opacity of a color value. + +hsla-adjust( *color*, *hue-offset*, *saturation-offset*, *lightness-offset*, *alpha-offset* ) + +## Parameters + +* *`color`* Any valid CSS color value +* *`hue-offset`* The percentage to offset the color hue +* *`saturation-offset`* The percentage to offset the color saturation +* *`lightness-offset`* The percentage to offset the color lightness +* *`alpha-offset`* The percentage to offset the color opacity + +## Returns + +The modified color value. + +## Examples + +```css +color: hsla-adjust( #f00 0 5 5 -10 ); +``` \ No newline at end of file diff --git a/docs/core/functions/l-adjust.md b/docs/core/functions/l-adjust.md new file mode 100644 index 0000000..28ac701 --- /dev/null +++ b/docs/core/functions/l-adjust.md @@ -0,0 +1,24 @@ + + +Adjust the lightness of a color value. + +l-adjust( *color*, *offset* ) + +## Parameters + +* *`color`* Any valid CSS color value +* *`offset`* The percentage to offset the color hue (percent mark optional) + +## Returns + +The modified color value. + +## Examples + +```css +color: l-adjust( deepskyblue 10 ); +``` diff --git a/docs/core/functions/math.md b/docs/core/functions/math.md new file mode 100644 index 0000000..2d65b40 --- /dev/null +++ b/docs/core/functions/math.md @@ -0,0 +1,19 @@ + + +Evaluate a raw mathematical expression. + +math( *expression* [, *unit*] ) + +## Examples + +```crush +font-size: math( 12 / 16, em ); +``` + +```css +font-size: 0.75em; +``` diff --git a/docs/core/functions/query.md b/docs/core/functions/query.md new file mode 100644 index 0000000..c9d5240 --- /dev/null +++ b/docs/core/functions/query.md @@ -0,0 +1,54 @@ + + +Copy a value from another rule. + +query( *target* [, *property-name* = default] [, *fallback*] ) + +## Parameters + +* *`target`* A rule selector, an abstract rule name or context keyword: `previous`, `next` (also `parent` and `top` within nested structures) +* *`property-name`* The CSS property name to copy, or just `default` to pass over. Defaults to the calling property +* *`fallback`* A CSS value to use if the target property does not exist + + +## Returns + +The referenced property value, or the fallback if it has not been set. + + +## Examples + + +```css +.foo { + width: 40em; + height: 100em; +} + +.bar { + width: query( .foo ); /* 40em */ + margin-top: query( .foo, height ); /* 100em */ + margin-bottom: query( .foo, default, 3em ); /* 3em */ +} +``` + +Using context keywords: + +```css +.foo { + width: 40em; + .bar { + width: 30em; + .baz: { + width: query( parent ); /* 30em */ + .qux { + width: query( top ); /* 40em */ + } + } + } +} +``` diff --git a/docs/core/functions/s-adjust.md b/docs/core/functions/s-adjust.md new file mode 100644 index 0000000..8d2cce1 --- /dev/null +++ b/docs/core/functions/s-adjust.md @@ -0,0 +1,25 @@ + + +Adjust the saturation of a color value. + +s-adjust( *color*, *offset* ) + +## Parameters + +* *`color`* Any valid CSS color value +* *`offset`* The percentage to offset the color hue (percent mark optional) + +## Returns + +The modified color value. + +## Examples + +```css +/* Desaturate */ +color: s-adjust( deepskyblue -100 ); +``` diff --git a/docs/core/functions/this.md b/docs/core/functions/this.md new file mode 100644 index 0000000..12029b6 --- /dev/null +++ b/docs/core/functions/this.md @@ -0,0 +1,39 @@ + + +Reference another property value from the same containing block. + +Restricted to referencing properties that don't already reference other properties. + +this( *property-name*, *fallback* ) + +## Parameters + +* *`property-name`* Property name +* *`fallback`* A CSS value + +## Returns + +The referenced property value, or the fallback if it has not been set. + +## Examples + +```css +.foo { + width: this( height ); + height: 100em; +} +``` + +******** + +```css +/* The following both fail because they create circular references. */ +.bar { + height: this( width ); + width: this( height ); +} +``` diff --git a/docs/core/inheritance.md b/docs/core/inheritance.md new file mode 100644 index 0000000..4473813 --- /dev/null +++ b/docs/core/inheritance.md @@ -0,0 +1,127 @@ + + +By using the `@extend` directive and passing it a named ruleset or selector from any other rule you can share styles more effectively across a stylesheet. + +[Abstract rules](#core--abstract) can be used if you just need to extend a generic set of declarations. + +```crush +.negative-text { + overflow: hidden; + text-indent: -9999px; +} + +.sidebar-headline { + @extend .negative-text; + background: url(http://github.com/headline.png) no-repeat; +} +``` + +```css +.negative-text, +.sidebar-headline { + overflow: hidden; + text-indent: -9999px; +} + +.sidebar-headline { + background: url(http://github.com/headline.png) no-repeat; +} +``` + +Inheritance is recursive: + +```crush +.one { color: pink; } +.two { @extend .one; } +.three { @extend .two; } +.four { @extend .three; } +``` + +```css +.one, .two, .three, .four { color: pink; } +``` + +## Referencing by name + +If you want to reference a rule without being concerned about later changes to the identifying selector use the `@name` directive: + +```crush +.foo123 { + @name foo; + text-decoration: underline; +} + +.bar { + @include foo; +} +.baz { + @extend foo; +} +``` + + +## Extending with pseudo classes/elements + +`@extend` arguments can adopt pseudo classes/elements by appending an exclamation mark: + +```crush +.link-base { + color: #bada55; + text-decoration: underline; +} +.link-base:hover, +.link-base:focus { + text-decoration: none; +} + +.link-footer { + @extend .link-base, .link-base:hover!, .link-base:focus!; + color: blue; +} +``` + +```css +.link-base, +.link-footer { + color: #bada55; + text-decoration: underline; +} + +.link-base:hover, +.link-base:focus, +.link-footer:hover, +.link-footer:focus { + text-decoration: none; +} + +.link-footer { + color: blue; +} +``` + +The same outcome can also be achieved with an [Abstract rule](#core--abstract) wrapper to simplify repeated use: + +```crush +.link-base { + color: #bada55; + text-decoration: underline; +} +.link-base:hover, +.link-base:focus { + text-decoration: none; +} + +@abstract link-base { + @extend .link-base, .link-base:hover!, .link-base:focus!; +} + +.link-footer { + @extend link-base; + color: blue; +} +``` + diff --git a/docs/core/loop.md b/docs/core/loop.md new file mode 100644 index 0000000..66ae6f7 --- /dev/null +++ b/docs/core/loop.md @@ -0,0 +1,41 @@ + + +For...in loops with lists and generator functions. + +```crush +@for fruit in apple, orange, pear { + .#(fruit) { + background-image: url("http://github.com/images/#(fruit).jpg"); + } +} +``` + +```css +.apple { background-image: url(http://github.com/images/apple.jpg); } +.orange { background-image: url(http://github.com/images/orange.jpg); } +.pear { background-image: url(http://github.com/images/pear.jpg); } +``` + +```crush +@for base in range(2, 24) { + @for i in range(1, #(base)) { + .grid-#(i)-of-#(base) { + width: math(#(i) / #(base) * 100, %); + } + } +} +``` + +```css +.grid-1-of-2 { width: 50%; } +.grid-2-of-2 { width: 100%; } +/* + Intermediate steps ommited. +*/ +.grid-23-of-24 { width: 95.83333%; } +.grid-24-of-24 { width: 100%; } +``` diff --git a/docs/core/mixins.md b/docs/core/mixins.md new file mode 100644 index 0000000..1348ff8 --- /dev/null +++ b/docs/core/mixins.md @@ -0,0 +1,95 @@ + + +Mixins make reusing small snippets of CSS much simpler. You define them with the `@mixin` directive. + +Positional arguments via the argument function `#()` extend the capability of mixins for repurposing in different contexts. + +```crush +@mixin display-font { + font-family: "Arial Black", sans-serif; + font-size: #(0); + letter-spacing: #(1); +} + +/* Another mixin with default arguments */ +@mixin blue-theme { + color: #(0 navy); + background-image: url("http://github.com/images/#(1%20cross-hatch).png"); +} + +/* Applying the mixins */ +.foo { + @include display-font(100%, .1em), blue-theme; +} +``` + +```css +.foo { + font-family: "Arial Black", sans-serif; + font-size: 100%; + letter-spacing: .1em; + color: navy; + background-image: url("http://github.com/images/cross-hatch.png"); +} +``` + +## Skipping arguments + +Mixin arguments can be skipped by using the **default** keyword: + +```crush +@mixin display-font { + font-size: #(0 100%); + letter-spacing: #(1); +} + +/* Applying the mixin skipping the first argument so the + default value is used instead */ +#foo { + @include display-font(default, .3em); +} +``` + +Sometimes you may need to use the same positional argument more than once. In this case the default value only needs to be specified once: + +```crush +@mixin square { + width: #(0 10px); + height: #(0); +} + +.foo { + @include square; +} +``` + +```css +#foo { + width: 10px; + height: 10px; +} +``` + + +## Mixing-in from other sources + +Normal rules and [abstract rules](#core--abstract) can also be used as static mixins without arguments: + +```crush +@abstract negative-text { + text-indent: -9999px; + overflow: hidden; +} + +#main-content .theme-border { + border: 1px solid maroon; +} + +.foo { + @include negative-text, #main-content .theme-border; +} +``` diff --git a/docs/core/nesting.md b/docs/core/nesting.md new file mode 100644 index 0000000..999be4f --- /dev/null +++ b/docs/core/nesting.md @@ -0,0 +1,49 @@ + + +Rules can be nested to avoid repetitive typing when scoping to a common parent selector. + +```crush +.homepage { + color: #333; + background: white; + .content { + p { + font-size: 110%; + } + } +} +``` + +```css +.homepage { + color: #333; + background: white; +} +.homepage .content p { + font-size: 110%; +} +``` + +## Parent referencing + +You can use the parent reference symbol `&` for placing the parent selector explicitly. + +```crush +.homepage { + .no-js & { + p { + font-size: 110%; + } + } +} +``` + +```css +.no-js .homepage p { + font-size: 110%; +} +``` diff --git a/docs/core/selector-aliases.md b/docs/core/selector-aliases.md new file mode 100644 index 0000000..ea9296d --- /dev/null +++ b/docs/core/selector-aliases.md @@ -0,0 +1,80 @@ + + +Selector aliases can be useful for grouping together common selector chains for reuse. + +They're defined with the `@selector` directive, and can be used anywhere you might use a pseudo class. + + +```crush +@selector heading :any(h1, h2, h3, h4, h5, h6); +@selector radio input[type="radio"]; +@selector hocus :any(:hover, :focus); + +/* Selector aliases with arguments */ +@selector class-prefix :any([class^="#(0)"], [class*=" #(0)"]); +@selector col :class-prefix(-col); + +.sidebar :heading { + color: honeydew; +} + +:radio { + margin-right: 4px; +} + +:col { + float: left; +} + +p a:hocus { + text-decoration: none; +} +``` + +```css +.sidebar h1, .sidebar h2, +.sidebar h3, .sidebar h4, +.sidebar h5, .sidebar h6 { + color: honeydew; +} + +input[type="radio"] { + margin-right: 4px; +} + +[class^="col-"], +[class*=" col-"] { + border: 1px solid rgba(0,0,0,.5); +} + +p a:hover, +p a:focus { + text-decoration: none; +} +``` + +## Selector splatting + +Selector splats are a special kind of selector alias that expand using passed arguments. + +```crush +@selector-splat input input[type="#(text)"]; + +form :input(time, text, url, email, number) { + border: 1px solid; +} +``` + +```css +form input[type="time"], +form input[type="text"], +form input[type="url"], +form input[type="email"], +form input[type="number"] { + border: 1px solid; +} +``` diff --git a/docs/core/selector-grouping.md b/docs/core/selector-grouping.md new file mode 100644 index 0000000..0cafa1f --- /dev/null +++ b/docs/core/selector-grouping.md @@ -0,0 +1,22 @@ + + +Selector grouping with the `:any` pseudo class (modelled after CSS4 :matches) simplifies the creation of complex selector chains. + +```crush +:any( .sidebar, .block ) a:any( :hover, :focus ) { + color: lemonchiffon; +} +``` + +```css +.block a:hover, +.block a:focus, +.sidebar a:hover, +.sidebar a:focus { + color: lemonchiffon; +} +``` diff --git a/docs/core/variables.md b/docs/core/variables.md new file mode 100644 index 0000000..117f53f --- /dev/null +++ b/docs/core/variables.md @@ -0,0 +1,62 @@ + + +Declare variables in your CSS with a `@set` directive and use them with the `$()` function. + +Variables can also be injected at runtime with the [vars option](#api--options). + + +```crush +/* Defining variables */ +@set { + dark: #333; + light: #F4F2E2; + smaller-screen: screen and (max-width: 800px); +} + +/* Using variables */ +@media $(smaller-screen) { + ul, p { + color: $(dark); + /* Using a fallback value with an undefined variable */ + background-color: $(accent-color, #ff0); + } +} +``` + +******* + +```css +/* Interpolation */ +.username::before { + content: "$(greeting)"; +} +``` + +## Conditionals + +Sections of CSS can be included and excluded on the basis of variable existence with the `@ifset` directive: + +```crush +@set foo #f00; +@set bar true; + +@ifset foo { + p { + color: $(foo); + } +} + +p { + font-size: 12px; + @ifset not foo { + line-height: 1.5; + } + @ifset bar(true) { + margin-bottom: 5px; + } +} +``` diff --git a/docs/getting-started/js.md b/docs/getting-started/js.md new file mode 100644 index 0000000..605727f --- /dev/null +++ b/docs/getting-started/js.md @@ -0,0 +1,26 @@ + + +This preprocessor is written in PHP, so as prerequisite you will need to have PHP installed on your system to use the JS api. + +```shell +npm install csscrush +``` + +All methods can take the standard options (camelCase) as the second argument. + +```php +const csscrush = require('csscrush'); + +// Compile. Returns promise. +csscrush.file('./styles.css', {sourceMap: true}); + +// Compile string of CSS. Returns promise. +csscrush.string('* {box-sizing: border-box;}'); + +// Compile and watch file. Returns event emitter (triggers 'data' on compile). +csscrush.watch('./styles.css'); +``` diff --git a/docs/getting-started/php.md b/docs/getting-started/php.md new file mode 100644 index 0000000..1c6f836 --- /dev/null +++ b/docs/getting-started/php.md @@ -0,0 +1,17 @@ + + +If you're using [Composer](http://getcomposer.org) you can use Crush in your project with the following line in your terminal: + +```shell +composer require css-crush/css-crush +``` + +If you're not using Composer yet just download the library ([zip](http://github.com/peteboere/css-crush/zipball/master) or [tar](http://github.com/peteboere/css-crush/tarball/master)) into a convenient location and require the bootstrap file: + +```php + +``` diff --git a/docs/plugins/aria.md b/docs/plugins/aria.md new file mode 100644 index 0000000..c00608d --- /dev/null +++ b/docs/plugins/aria.md @@ -0,0 +1,21 @@ + +Pseudo classes for working with ARIA roles, states and properties. + + * [ARIA roles spec](http://www.w3.org/TR/wai-aria/roles) + * [ARIA states and properties spec](http://www.w3.org/TR/wai-aria/states_and_properties) + +````crush +:role(tablist) {...} +:aria-expanded {...} +:aria-expanded(false) {...} +:aria-label {...} +:aria-label(foobarbaz) {...} +```` + +````css +[role="tablist"] {...} +[aria-expanded="true"] {...} +[aria-expanded="false"] {...} +[aria-label] {...} +[aria-label="foobarbaz"] {...} +```` diff --git a/docs/plugins/canvas.md b/docs/plugins/canvas.md new file mode 100644 index 0000000..df6994d --- /dev/null +++ b/docs/plugins/canvas.md @@ -0,0 +1,55 @@ + +Bitmap image generator. + +Requires the GD image library bundled with PHP. + +```crush +/* Create square semi-opaque png. */ +@canvas foo { + width: 50; + height: 50; + fill: rgba(255, 0, 0, .5); +} + +body { + background: white canvas(foo); +} +``` + +***** + +```crush +/* White to transparent east facing gradient with 10px + margin and background fill. */ +@canvas horz-gradient { + width: #(0); + height: 150; + fill: canvas-linear-gradient(to right, #(1 white), #(2 rgba(255,255,255,0))); + background-fill: powderblue; + margin: 10; +} + +/* Rectangle 300x150. */ +body { + background: canvas(horz-gradient, 300); +} +/* Flipped gradient, using canvas-data() to generate a data URI. */ +.bar { + background: canvas-data(horz-gradient, 100, rgba(255,255,255,0), white); +} +``` + +***** + +```crush +/* Google logo resized to 400px width and given a sepia effect. */ +@canvas sepia { + src: url(http://www.google.com/images/logo.png); + width: 400; + canvas-filter: greyscale() colorize(45, 45, 0); +} + +.bar { + background: canvas(sepia); +} +``` diff --git a/docs/plugins/ease.md b/docs/plugins/ease.md new file mode 100644 index 0000000..37ce5f9 --- /dev/null +++ b/docs/plugins/ease.md @@ -0,0 +1,37 @@ + +Expanded easing keywords for transitions. + +* ease-in-out-back +* ease-in-out-circ +* ease-in-out-expo +* ease-in-out-sine +* ease-in-out-quint +* ease-in-out-quart +* ease-in-out-cubic +* ease-in-out-quad +* ease-out-back +* ease-out-circ +* ease-out-expo +* ease-out-sine +* ease-out-quint +* ease-out-quart +* ease-out-cubic +* ease-out-quad +* ease-in-back +* ease-in-circ +* ease-in-expo +* ease-in-sine +* ease-in-quint +* ease-in-quart +* ease-in-cubic +* ease-in-quad + +See [easing demos](http://easings.net) for live examples. + +```crush +transition: .2s ease-in-quad; +``` + +```css +transition: .2s cubic-bezier(.550,.085,.680,.530); +``` diff --git a/docs/plugins/forms.md b/docs/plugins/forms.md new file mode 100644 index 0000000..3f4fcbc --- /dev/null +++ b/docs/plugins/forms.md @@ -0,0 +1,16 @@ + +Pseudo classes for working with forms. + +```crush +:input(date, search, email) {...} +:checkbox {...} +:radio {...} +:text {...} +``` + +```css +input[type="date"], input[type="search"], input[type="email"] {...} +input[type="checkbox"] {...} +input[type="radio"] {...} +input[type="text"] {...} +``` diff --git a/docs/plugins/hocus-pocus.md b/docs/plugins/hocus-pocus.md new file mode 100644 index 0000000..b8f3efa --- /dev/null +++ b/docs/plugins/hocus-pocus.md @@ -0,0 +1,12 @@ + +Composite :hover/:focus/:active pseudo classes. + +```crush +a:hocus { color: red; } +a:pocus { color: red; } +``` + +```css +a:hover, a:focus { color: red; } +a:hover, a:focus, a:active { color: red; } +``` diff --git a/docs/plugins/property-sorter.md b/docs/plugins/property-sorter.md new file mode 100644 index 0000000..cd17817 --- /dev/null +++ b/docs/plugins/property-sorter.md @@ -0,0 +1,21 @@ + +Property sorting. + +Examples use the predefined property sorting table. To define a custom sorting order pass an array to `csscrush_set_property_sort_order()` + + +```crush +color: red; +background: #000; +opacity: .5; +display: block; +position: absolute; +``` + +```css +position: absolute; +display: block; +opacity: .5; +color: red; +background: #000; +``` diff --git a/docs/plugins/svg-gradients.md b/docs/plugins/svg-gradients.md new file mode 100644 index 0000000..66ff410 --- /dev/null +++ b/docs/plugins/svg-gradients.md @@ -0,0 +1,48 @@ + +Functions for creating SVG gradients with a CSS gradient like syntax. + +Primarily useful for supporting Internet Explorer 9. + +## svg-linear-gradent() + +Syntax is the same as [linear-gradient()](http://dev.w3.org/csswg/css3-images/#linear-gradient) + +```syntax +svg-linear-gradent( [ | to ,]? [, ]+ ) +``` + +### Returns + +A base64 encoded svg data-uri. + +### Known issues + +Color stops can only take percentage value offsets. + +```css +background-image: svg-linear-gradient( to top left, #fff, rgba(255,255,255,0) 80% ); +background-image: svg-linear-gradient( 35deg, red, gold 20%, powderblue ); +``` + + +## svg-radial-gradent() + +Syntax is similar to but more limited than [radial-gradient()](http://dev.w3.org/csswg/css3-images/#radial-gradient) + +```syntax +svg-radial-gradent( [ | at ,]? [, ]+ ) +``` + +### Returns + +A base64 encoded svg data-uri. + +### Known issues + +Color stops can only take percentage value offsets. +No control over shape - only circular gradients - however, the generated image can be stretched with background-size. + +```css +background-image: svg-radial-gradient( at center, red, blue 50%, yellow ); +background-image: svg-radial-gradient( 100% 50%, rgba(255,255,255,.5), rgba(255,255,255,0) ); +``` diff --git a/docs/plugins/svg.md b/docs/plugins/svg.md new file mode 100644 index 0000000..facec0d --- /dev/null +++ b/docs/plugins/svg.md @@ -0,0 +1,74 @@ + +Define and embed simple SVG elements, paths and effects inside CSS + + +```crush +@svg foo { + type: star; + star-points: #(0 5); + radius: 100 50; + margin: 20; + stroke: black; + fill: red; + fill-opacity: .5; +} + +/* Embed SVG with svg() function (generates an svg file). */ +body { + background: svg(foo); +} +/* As above but a 3 point star creating a data URI instead of a file. */ +body { + background: svg-data(foo, 3); +} +``` + +******* + +```crush +/* Using path data and stroke styles to create a plus sign. */ +@svg plus { + d: "M0,5 h10 M5,0 v10"; + width: 10; + height: 10; + stroke: white; + stroke-linecap: round; + stroke-width: 2; +} +``` + + +******* + +```crush +/* Skewed circle with radial gradient fill and drop shadow. */ +@svg circle { + type: circle; + transform: skewX(30); + diameter: 60; + margin: 20; + fill: svg-radial-gradient(at top right, gold 50%, red); + drop-shadow: 2 2 0 rgba(0,0,0,1); +} +``` + +******* + +```crush +/* 8-sided polygon with an image fill. + Note: images usually have to be converted to data URIs, see known issues below. */ +@svg pattern { + type: polygon; + sides: 8; + diameter: 180; + margin: 20; + fill: pattern(data-uri(kitten.jpg), scale(1) translate(-100 0)); + fill-opacity: .8; +} +``` + + +### Known issues + +Firefox [does not allow linked images](https://bugzilla.mozilla.org/show_bug.cgi?id=628747#c0) (or other svg) when svg is in "svg as image" mode. + diff --git a/js/index.d.ts b/js/index.d.ts new file mode 100644 index 0000000..8e7e7f1 --- /dev/null +++ b/js/index.d.ts @@ -0,0 +1,78 @@ +/** + * @param {string} file - CSS file path + * @param {CSSCrushOptions} [options] + * @returns {CSSCrushProcess} + */ +export function watch(file: string, options?: CSSCrushOptions): CSSCrushProcess; +/** + * @param {string} file - CSS file path + * @param {CSSCrushOptions} [options] + * @returns {Promise} + */ +export function file(file: string, options?: CSSCrushOptions): Promise; +/** + * @param {string} string - CSS text + * @param {CSSCrushOptions} [options] + * @returns {Promise} + */ +export function string(string: string, options?: CSSCrushOptions): Promise; +declare namespace _default { + export { watch }; + export { file }; + export { string }; +} +export default _default; +export type CSSCrushOptions = { + sourceMap?: boolean; + boilerplate?: boolean; + minify?: boolean; + vendorTarget?: ('all' | 'none' | 'moz' | 'ms' | 'webkit'); + plugins?: string | [string]; + importPath?: string | [string]; + newlines?: ('use-platform' | 'windows' | 'unix'); + formatter?: ('block' | 'single-line' | 'padded'); + input?: string; + context?: string; + output?: string; + vars?: object; +}; +export type CSSCrushProcessOptions = CSSCrushOptions & { + stdIn?: string; + watch?: boolean; +}; +/** + * @typedef {object} CSSCrushOptions + * @property {boolean} [sourceMap] + * @property {boolean} [boilerplate] + * @property {boolean} [minify=true] + * @property {('all' | 'none' | 'moz' | 'ms' | 'webkit')} [vendorTarget='all'] + * @property {string | [string]} [plugins] + * @property {string | [string]} [importPath] + * @property {('use-platform' | 'windows' | 'unix')} [newlines='use-platform'] + * @property {('block' | 'single-line' | 'padded')} [formatter] + * @property {string} [input] + * @property {string} [context] + * @property {string} [output] + * @property {object} [vars] + */ +/** + * @typedef {CSSCrushOptions & { + * stdIn?: string; + * watch?: boolean; + * }} CSSCrushProcessOptions + */ +declare class CSSCrushProcess extends EventEmitter { + /** + * @param {CSSCrushProcessOptions} options + * @returns {Promise} + */ + exec(options: CSSCrushProcessOptions): Promise; + /** + * @param {CSSCrushProcessOptions} options + * @returns {CSSCrushProcess} + */ + watch(options: CSSCrushProcessOptions): CSSCrushProcess; + kill(): void; + #private; +} +import { EventEmitter } from 'node:events'; diff --git a/js/index.js b/js/index.js new file mode 100644 index 0000000..cbb469e --- /dev/null +++ b/js/index.js @@ -0,0 +1,335 @@ +/*eslint no-control-regex: 0*/ +import os from 'node:os'; +import fs from 'node:fs'; +import pathUtil from 'node:path'; +import {fileURLToPath} from 'node:url'; +import querystring from 'node:querystring'; +import {EventEmitter} from 'node:events'; +import {exec} from 'node:child_process'; +import {createHash} from 'node:crypto'; +import glob from 'glob'; + +const cliPath = pathUtil + .resolve(pathUtil + .dirname(fileURLToPath(import.meta.url)), '../cli.php'); + +const processes = []; + +for (const event of [ + 'exit', + 'SIGINT', + 'SIGTERM', + 'SIGUSR1', + 'SIGUSR2', + 'uncaughtException', +]) { + process.on(event, exit); +} + +/** + * @typedef {object} CSSCrushOptions + * @property {boolean} [sourceMap] + * @property {boolean} [boilerplate] + * @property {boolean} [minify=true] + * @property {('all' | 'none' | 'moz' | 'ms' | 'webkit')} [vendorTarget='all'] + * @property {string | [string]} [plugins] + * @property {string | [string]} [importPath] + * @property {('use-platform' | 'windows' | 'unix')} [newlines='use-platform'] + * @property {('block' | 'single-line' | 'padded')} [formatter] + * @property {string} [input] + * @property {string} [context] + * @property {string} [output] + * @property {object} [vars] + */ +/** + * @typedef {CSSCrushOptions & { + * stdIn?: string; + * watch?: boolean; + * }} CSSCrushProcessOptions + */ + +class CSSCrushProcess extends EventEmitter { + + #process; + + /** + * @param {CSSCrushProcessOptions} options + * @returns {Promise} + */ + exec(options) { + return new Promise(resolve => { + let command = this.#assembleCommand(options); + const {stdIn} = options; + if (stdIn) { + command = `echo '${stdIn.replace(/'/g, "\\'")}' | ${command}`; + } + processExec(command, (error, stdout, stderr) => { + process.stderr.write(stderr.toString()); + if (error) { + return resolve(false); + } + const stdOut = stdout.toString(); + if (stdIn) { + process.stdout.write(stdOut); + } + return resolve(stdOut || true); + }); + }); + } + + /** + * @param {CSSCrushProcessOptions} options + * @returns {CSSCrushProcess} + */ + watch(options) { + options.watch = true; + const command = this.#assembleCommand(options); + this.#process = processExec(command); + + /* + * Emitting 'error' events from EventEmitter without + * any error listener will throw uncaught exception. + */ + this.on('error', () => {}); + + this.#process.stderr.on('data', msg => { + msg = msg.toString(); + process.stderr.write(msg); + msg = msg.replace(/\x1B\[[^m]*m/g, '').trim(); + + const [, signal, detail] = /^([A-Z]+):\s*(.+)/i.exec(msg) || []; + const {input, output} = options; + const eventData = { + signal, + options: { + input: input ? pathUtil.resolve(input) : null, + output: output ? pathUtil.resolve(output) : null, + }, + }; + + if (/^(WARNING|ERROR)$/.test(signal)) { + const error = new Error(detail); + Object.assign(error, eventData, {severity: signal.toLowerCase()}); + this.emit('error', error); + } + else { + this.emit('data', {message: detail, ...eventData}); + } + }); + + this.#process.on('exit', exit); + + return this; + } + + kill() { + this.#process?.kill(); + } + + #assembleCommand(options) { + return `${process.env.CSSCRUSH_PHP_BIN || 'php'} ${cliPath} ${this.#stringifyOptions(options)}`; + } + + #stringifyOptions(options) { + const args = []; + options = {...options}; + for (let name in options) { + // Normalize to hypenated case. + const cssCase = name.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`); + if (name !== cssCase) { + options[cssCase] = options[name]; + delete options[name]; + name = cssCase; + } + let value = options[name]; + switch (name) { + // Booleans. + case 'watch': + case 'source-map': + case 'boilerplate': + if (value) { + args.push(`--${name}`); + } + else if (value === false) { + args.push(`--${name}=false`); + } + break; + case 'minify': + if (! value) { + args.push(`--pretty`); + } + break; + // Array/list values. + case 'vendor-target': + case 'plugins': + case 'import-path': + if (value) { + value = (Array.isArray(value) ? value : [value]).join(','); + args.push(`--${name}="${value}"`); + } + break; + // String values. + case 'newlines': + case 'formatter': + case 'input': + case 'context': + case 'output': + if (value) { + args.push(`--${name}="${value}"`); + } + break; + case 'vars': + args.push(`--${name}="${querystring.stringify(value)}"`); + break; + } + } + + return args.join(' '); + } +} + +export default { + watch, + file, + string, +}; + +/** + * @param {string} file - CSS file path + * @param {CSSCrushOptions} [options] + * @returns {CSSCrushProcess} + */ +export function watch(file, options={}) { + ({file: options.input, context: options.context} = resolveFile(file, {watch: true})); + return (new CSSCrushProcess()).watch(options); +} + +/** + * @param {string} file - CSS file path + * @param {CSSCrushOptions} [options] + * @returns {Promise} + */ +export function file(file, options={}) { + ({file: options.input, context: options.context} = resolveFile(file)); + return (new CSSCrushProcess()).exec(options); +} + +/** + * @param {string} string - CSS text + * @param {CSSCrushOptions} [options] + * @returns {Promise} + */ +export function string(string, options={}) { + + /** @type {CSSCrushProcessOptions} */ (options).stdIn = string; + return (new CSSCrushProcess()).exec(options); +} + +/** + * @param {string} input + * @param {object} [options] + * @param {boolean} [options.watch] + */ +function resolveFile(input, {watch}={}) { + + if (Array.isArray(input)) { + + let initial; + let previous; + + /* + * Generate temporary file containing entrypoints. + * Poll to update on additions and deletions. + */ + const poller = () => { + const result = resolveInputs(input); + + if (result.fingerprint !== previous?.fingerprint) { + fs.writeFileSync(initial?.file || result.file, result.content, { + mode: 0o777, + }); + } + + initial ||= result; + previous = result; + + if (watch) { + setTimeout(poller, 2000); + } + + return result; + }; + + return poller(); + } + + return { + file: input, + }; +} + +function resolveInputs(fileGlobs) { + + const result = {}; + + /** @type {Set | array} */ + let files = new Set(); + + for (const it of fileGlobs) { + for (const path of (glob.sync(it) || []).sort()) { + files.add(path); + } + } + + if (! files.size) { + return result; + } + + files = [...files]; + + const rootPath = files + .shift(); + const context = pathUtil + .dirname(rootPath); + const rootFile = pathUtil + .basename(rootPath); + + const content = [rootFile] + .concat(files + .map(it => pathUtil + .relative(context, it))) + .map(it => `@import "http://github.com/$%7Bit%7D";`) + .join('\n'); + + const fingerprint = createHash('md5') + .update(content) + .digest('hex'); + + const outputDir = `${os.tmpdir()}/csscrush`; + if (! fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { + mode: 0o777, + }); + } + + return Object + .assign(result, { + context, + content, + fingerprint, + file: `${outputDir}/${fingerprint}.css`, + }); +} + +function processExec(command, done) { + processes.push(exec(command, done)); + return processes.at(-1); +} + +function exit() { + let proc; + while ((proc = processes.pop())) { + proc?.kill(); + } + process.exit(); +} diff --git a/js/tests/test.js b/js/tests/test.js new file mode 100644 index 0000000..93aff96 --- /dev/null +++ b/js/tests/test.js @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; +import {EventEmitter} from 'node:events'; +import {writeFileSync, readFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import pathUtil from 'node:path'; +import * as csscrush from "../index.js"; + +describe('csscrush.string', () => { + + it('should minify CSS text', async () => { + + const result = await csscrush + .string('foo {color: #ff0000;}'); + + assert.strictEqual(result, 'foo{color:#f00}\n'); + }); +}); + +describe('csscrush.file', () => { + + it('should minify CSS text', async () => { + + const cssText = 'foo {color: #ff0000;}'; + + const testFile = pathUtil + .join(tmpdir(), 'test.css'); + + writeFileSync(testFile, cssText); + + const result = await csscrush + .file(testFile); + + assert(result, 'foo{color:#f00}\n'); + }); +}); + +describe('csscrush.watch', () => { + + it('should minify CSS text', async () => { + + const cssText = 'foo {color: #ff0000;}'; + + const testFile = pathUtil + .join(tmpdir(), 'test.css'); + + const testFileOutput = `${testFile}.result.css`; + + writeFileSync(testFile, cssText); + + const result = await csscrush + .watch(testFile, { + output: testFileOutput, + boilerplate: false, + }); + + assert(result instanceof EventEmitter); + + setTimeout(() => { + writeFileSync(testFile, 'foo {color: #ffffff;}'); + }); + + const event = await new Promise((resolve, reject) => { + result + .on('error', error => { + reject(error); + }) + .on('data', resolve); + }); + + assert.strictEqual(event?.signal, 'WRITE'); + + const contents = readFileSync(testFileOutput) + .toString(); + + assert.strictEqual(contents, 'foo{color:#fff}'); + + result.kill(); + }); +}); diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..5d104c7 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,21 @@ +{ + "include": [ + "./js/index.js" + ], + "exclude": [ + "node_modules", + ], + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "baseUrl": ".", + "checkJs": true, + "declaration": true, + "emitDeclarationOnly": true, + "maxNodeModuleJsDepth": 0, + "module": "esnext", + "moduleResolution": "nodenext", + "noEmit": false, + "outDir": "./js", + "target": "esnext", + }, +} diff --git a/lib/Color.php b/lib/Color.php deleted file mode 100644 index c00d4e4..0000000 --- a/lib/Color.php +++ /dev/null @@ -1,165 +0,0 @@ - $rgb ) { - $rgb = array_map( 'intval', explode( ',', $rgb ) ); - self::$keywords[ $word ] = $rgb; - } - } - } - return self::$keywords; - } - - /** - * http://mjijackson.com/2008/02/ - * rgb-to-hsl-and-rgb-to-hsv-color-model-conversion-algorithms-in-javascript - * - * Converts an RGB color value to HSL. Conversion formula - * adapted from http://en.wikipedia.org/wiki/HSL_color_space. - * Assumes r, g, and b are contained in the set [0, 255] and - * returns h, s, and l in the set [0, 1]. - */ - public static function rgbToHsl ( array $rgb ) { - - list( $r, $g, $b ) = $rgb; - $r /= 255; - $g /= 255; - $b /= 255; - $max = max( $r, $g, $b ); - $min = min( $r, $g, $b ); - $h; - $s; - $l = ( $max + $min ) / 2; - - if ( $max == $min ) { - $h = $s = 0; - } - else { - $d = $max - $min; - $s = $l > 0.5 ? $d / ( 2 - $max - $min ) : $d / ( $max + $min ); - switch( $max ) { - case $r: - $h = ( $g - $b ) / $d + ( $g < $b ? 6 : 0 ); - break; - case $g: - $h = ( $b - $r ) / $d + 2; - break; - case $b: - $h = ( $r - $g ) / $d + 4; - break; - } - $h /= 6; - } - - return array( $h, $s, $l ); - } - - /** - * http://mjijackson.com/2008/02/ - * rgb-to-hsl-and-rgb-to-hsv-color-model-conversion-algorithms-in-javascript - * - * Converts an HSL color value to RGB. Conversion formula - * adapted from http://en.wikipedia.org/wiki/HSL_color_space. - * Assumes h, s, and l are contained in the set [0, 1] and - * returns r, g, and b in the set [0, 255]. - */ - public static function hslToRgb ( array $hsl ) { - list( $h, $s, $l ) = $hsl; - $r; - $g; - $b; - if ( $s == 0 ) { - $r = $g = $b = $l; - } - else { - $q = $l < 0.5 ? $l * ( 1 + $s ) : $l + $s - $l * $s; - $p = 2 * $l - $q; - $r = self::hueToRgb( $p, $q, $h + 1 / 3 ); - $g = self::hueToRgb( $p, $q, $h ); - $b = self::hueToRgb( $p, $q, $h - 1 / 3 ); - } - return array( round( $r * 255 ), round( $g * 255 ), round( $b * 255 ) ); - } - - // Convert percentages to points (0-255) - public static function normalizeCssRgb ( array $rgb ) { - foreach ( $rgb as &$val ) { - if ( strpos( $val, '%' ) !== false ) { - $val = str_replace( '%', '', $val ); - $val = round( $val * 2.55 ); - } - } - return $rgb; - } - - public static function cssHslToRgb ( array $hsl ) { - - // Normalize the hue degree value then convert to float - $h = array_shift( $hsl ); - $h = $h % 360; - if ( $h < 0 ) { - $h = 360 + $h; - } - $h = $h / 360; - - // Convert s and l to floats - foreach ( $hsl as &$val ) { - $val = str_replace( '%', '', $val ); - $val /= 100; - } - list( $s, $l ) = $hsl; - - $hsl = array( $h, $s, $l ); - $rgb = self::hslToRgb( $hsl ); - - return $rgb; - } - - public static function hueToRgb ( $p, $q, $t ) { - if ( $t < 0 ) $t += 1; - if ( $t > 1 ) $t -= 1; - if ( $t < 1/6 ) return $p + ( $q - $p ) * 6 * $t; - if ( $t < 1/2 ) return $q; - if ( $t < 2/3 ) return $p + ( $q - $p ) * ( 2 / 3 - $t ) * 6; - return $p; - } - - public static function rgbToHex ( array $rgb ) { - $hex_out = '#'; - foreach ( $rgb as $val ) { - $hex_out .= str_pad( dechex( $val ), 2, '0', STR_PAD_LEFT ); - } - return $hex_out; - } - - public static function hexToRgb ( $hex ) { - $hex = substr( $hex, 1 ); - - // Handle shortened format - if ( strlen( $hex ) === 3 ) { - $long_hex = array(); - foreach ( str_split( $hex ) as $val ) { - $long_hex[] = $val . $val; - } - $hex = $long_hex; - } - else { - $hex = str_split( $hex, 2 ); - } - return array_map( 'hexdec', $hex ); - } - -} \ No newline at end of file diff --git a/lib/Core.php b/lib/Core.php deleted file mode 100644 index 1dab671..0000000 --- a/lib/Core.php +++ /dev/null @@ -1,1190 +0,0 @@ - '! - @import\s+ # import at-rule - (?: - url\(\s*([^\)]+)\s*\) # url function - | # or - ([_s\d]+) # string token - ) - \s*([^;]*);? # media argument - !x', - 'variables' => '!@(?:variables|define)\s*([^\{]*)\{\s*(.*?)\s*\};?!s', - 'atRule' => '!@([-a-z_]+)\s*([^\{]*)\{\s*(.*?)\s*\};?!s', - 'comment' => '!/\*(.*?)\*/!s', - 'string' => '!(\'|"|`)(?:\\1|[^\1])*?\1!', - // As an exception we treat @font-face and @page rules like standard rules - 'rule' => '! - (\n(?:[^@{}]+|@(?:font-face|page)[^{]*)) # The selector - \{([^{}]*)\} # The declaration block - !x', - 'token' => array( - 'comment' => '!___c\d+___!', - 'string' => '!___s\d+___!', - 'rule' => '!___r\d+___!', - 'paren' => '!___p\d+___!', - ), - 'function' => array( - 'var' => '!(?: - ([^a-z0-9_-]) - var\(\s*([a-z0-9_-]+)\s*\) - | - \$\(\s*([a-z0-9_-]+)\s*\) # Dollar syntax - )!ix', - 'match' => '!(^|[^a-z0-9_-])([a-z_-]+)(___p\d+___)!i', - ), - 'vendorPrefix' => '!^-([a-z]+)-([a-z-]+)!', - 'absoluteUrl' => '!^https?://!', - ); - - // Init called once manually post class definition - public static function init ( $current_dir ) { - - self::$location = $current_dir; - - self::$config = $config = new stdClass; - $config->file = '.' . __CLASS__; - $config->data = null; - $config->path = null; - $config->baseDir = null; - $config->baseURL = null; - - // Get normalized document root reference: no symlink, forward slashes, no trailing slashes - $docRoot = null; - if ( isset( $_SERVER[ 'DOCUMENT_ROOT' ] ) ) { - $docRoot = realpath( $_SERVER[ 'DOCUMENT_ROOT' ] ); - } - else { - // Probably IIS - $scriptname = $_SERVER[ 'SCRIPT_NAME' ]; - $fullpath = realpath( basename( $scriptname ) ); - $docRoot = substr( $fullpath, 0, stripos( $fullpath, $scriptname ) ); - } - $config->docRoot = csscrush_util::normalizeSystemPath( $docRoot ); - - // Casting to objects for ease of use - self::$regex = (object) self::$regex; - self::$regex->token = (object) self::$regex->token; - self::$regex->function = (object) self::$regex->function; - } - - // Aliases and macros loader - protected static function loadAssets () { - - // Find an aliases file in the root directory - // a local file will overrides the default - $aliases_file = csscrush_util::find( 'Aliases-local.ini', 'Aliases.ini' ); - - // Load aliases file if it exists - if ( $aliases_file ) { - if ( $result = @parse_ini_file( $aliases_file, true ) ) { - self::$aliasesRaw = $result; - - // Value aliases require a little preprocessing - if ( isset( self::$aliasesRaw[ 'values' ] ) ) { - $store = array(); - foreach ( self::$aliasesRaw[ 'values' ] as $prop_val => $aliases ) { - list( $prop, $value ) = array_map( 'trim', explode( ':', $prop_val ) ); - $store[ $prop ][ $value ] = $aliases; - } - self::$aliasesRaw[ 'values' ] = $store; - } - } - else { - trigger_error( __METHOD__ . ": Aliases file could not be parsed.\n", E_USER_NOTICE ); - } - } - else { - trigger_error( __METHOD__ . ": Aliases file not found.\n", E_USER_NOTICE ); - } - - // Find a plugins file in the root directory - // a local file will overrides the default - $plugins_file = csscrush_util::find( 'Plugins-local.ini', 'Plugins.ini' ); - - // Load plugins - if ( $plugins_file ) { - if ( $result = @parse_ini_file( $plugins_file ) ) { - foreach ( $result[ 'plugins' ] as $plugin_file ) { - $path = self::$location . "/plugins/$plugin_file"; - if ( file_exists( $path ) ) { - require_once $path; - } - else { - trigger_error( __METHOD__ . ": Plugin file $plugin_file not found.\n", E_USER_NOTICE ); - } - } - } - else { - trigger_error( __METHOD__ . ": Plugin file could not be parsed.\n", E_USER_NOTICE ); - } - } - } - - // Initialize config data, create config cache file if needed - protected static function loadCacheData () { - $config = self::$config; - if ( - file_exists( $config->path ) and - $config->data and - $config->data[ 'originPath' ] == $config->path - ) { - // Already loaded and config file exists in the current directory - return; - } - - $configFileExists = file_exists( $config->path ); - $configFileWritable = $configFileExists ? is_writable( $config->path ) : false; - - if ( $configFileExists and $configFileWritable ) { - // Load from file - $config->data = unserialize( file_get_contents( $config->path ) ); - } - else { - // Config file may exist but not be writable (may not be visible in some ftp situations?) - if ( $configFileExists ) { - if ( ! @unlink( $config->path ) ) { - trigger_error( __METHOD__ . ": Could not delete config data file.\n", E_USER_NOTICE ); - } - } - // Create - self::log( 'Creating config data file' ); - file_put_contents( $config->path, serialize( array() ) ); - $config->data = array(); - } - } - - // Establish the hostfile directory and optionally test it's writable - protected static function setPath ( $new_dir, $write_test = true ) { - - $config = self::$config; - $docRoot = $config->docRoot; - - if ( strpos( $new_dir, $docRoot ) !== 0 ) { - // Not a system path - $new_dir = realpath( "$docRoot/$new_dir" ); - } - - $pathtest = true; - if ( ! file_exists( $new_dir ) ) { - trigger_error( __METHOD__ . ": directory '$new_dir' doesn't exist.\n", E_USER_WARNING ); - $pathtest = false; - } - else if ( $write_test and ! is_writable( $new_dir ) ) { - self::log( 'Attempting to change permissions' ); - if ( ! @chmod( $new_dir, 0755 ) ) { - trigger_error( __METHOD__ . ": directory '$new_dir' is unwritable.\n", E_USER_WARNING ); - self::log( 'Unable to update permissions' ); - $pathtest = false; - } - else { - self::log( 'Permissions updated' ); - } - } - - $config->path = "$new_dir/$config->file"; - $config->baseDir = $new_dir; - $config->baseURL = substr( $new_dir, strlen( $docRoot ) ); - - return $pathtest; - } - - - ############# - # Public API - - /** - * Process host CSS file and return a new compiled file - * - * @param string $file URL or System path to the host CSS file - * @param mixed $options An array of options or null - * @return string The public path to the compiled file or an empty string - */ - public static function file ( $file, $options = null ) { - - $config = self::$config; - - // Reset for current process - self::reset(); - - // Since we're comparing strings, we need to iron out OS differences - $file = str_replace( '\\', '/', $file ); - $docRoot = $config->docRoot; - - $pathtest = true; - if ( strpos( $file, $docRoot ) === 0 ) { - // System path - $pathtest = self::setPath( dirname( $file ) ); - } - else if ( strpos( $file, '/' ) === 0 ) { - // WWW root path - $pathtest = self::setPath( dirname( $docRoot . $file ) ); - } - else { - // Relative path - $pathtest = self::setPath( dirname( dirname( __FILE__ ) . '/' . $file ) ); - } - - if ( ! $pathtest ) { - // Main directory not found or is not writable return an empty string - return ''; - } - - // Load the data of previously cached files to self::$config - self::loadCacheData(); - - // Get the merged options, stored to self::$options - $options = self::getOptions( $options ); - - // Get the hostfile object - $hostfile = self::getHostfile( $file ); - - // Compiled filename we're searching for - // This can be given as an option, uses the host-filename by default - $baseCompileName = basename( $hostfile->name, '.css' ); - if ( !empty( $options[ 'output_file' ] ) ) { - $baseCompileName = basename( $options[ 'output_file' ], '.css' ); - } - self::$compileName = $baseCompileName . self::$COMPILE_SUFFIX; - - // If cache is enabled check for a valid compiled file - if ( $options[ 'cache' ] === true ) { - $validCompliledFile = self::validateCache( $hostfile ); - if ( is_string( $validCompliledFile ) ) { - return $validCompliledFile; - } - } - - // Collate hostfile and imports - $stream = csscrush_importer::hostfile( $hostfile ); - - // Compile - $stream = self::compile( $stream ); - - // Create file and return path. Return empty string on failure - if ( file_put_contents( "$config->baseDir/" . self::$compileName, $stream ) ) { - return "$config->baseURL/" . self::$compileName . - ( $options[ 'versioning' ] ? '?' . time() : '' ); - } - else { - return ''; - } - } - - /** - * Process host CSS file and return an HTML link tag with populated href - * - * @param string $file Absolute or relative path to the host CSS file - * @param mixed $options An array of options or null - * @param array $attributes An array of HTML attributes - * @return string HTML link tag or error message inside HTML comment - */ - public static function tag ( $file, $options = null, $attributes = array() ) { - $file = self::file( $file, $options ); - if ( !empty( $file ) ) { - // On success return the tag with any custom attributes - $attributes[ 'rel' ] = "stylesheet"; - $attributes[ 'href' ] = $file; - $attr_string = csscrush_util::htmlAttributes( $attributes ); - return "\n"; - } - else { - // Return an HTML comment with message on failure - $class = __CLASS__; - return "\n"; - } - } - - /** - * Process host CSS file and return CSS as text wrapped in html style tags - * - * @param string $file Absolute or relative path to the host CSS file - * @param mixed $options An array of options or null - * @param array $attributes An array of HTML attributes, set false to return CSS text without tag - * @return string HTML link tag or error message inside HTML comment - */ - public static function inline ( $file, $options = null, $attributes = array() ) { - - $file = self::file( $file, $options ); - if ( !empty( $file ) ) { - // On success fetch the CSS text - $content = file_get_contents( self::$config->baseDir . '/' . self::$compileName ); - $tag_open = ''; - $tag_close = ''; - if ( is_array( $attributes ) ) { - $attr_string = csscrush_util::htmlAttributes( $attributes ); - $tag_open = ""; - $tag_close = ''; - } - return "$tag_open{$content}$tag_close\n"; - } - else { - // Return an HTML comment with message on failure - $class = __CLASS__; - return "\n"; - } - } - - /** - * Compile a raw string of CSS string and return it - * - * @param string $string CSS text - * @param mixed $options An array of options or null - * @return string CSS text - */ - public static function string ( $string, $options = null ) { - // Reset for current process - self::reset(); - self::getOptions( $options ); - - // Set the path context if one is given - if ( isset( $options[ 'import_context' ] ) && ! empty( $options[ 'import_context' ] ) ) { - self::setPath( $options[ 'import_context' ] ); - } - - // It's not associated with a real file so we create an 'empty' hostfile object - $hostfile = self::getHostfile(); - - // Set the string on the object - $hostfile->string = $string; - - // Import files may be ignored - if ( isset( $options[ 'no_import' ] ) ) { - $hostfile->importIgnore = true; - } - - // Collate imports - $stream = csscrush_importer::hostfile( $hostfile ); - - // Return compiled string - return self::compile( $stream ); - } - - /** - * Add variables globally - * - * @param mixed $var Assoc array of variable names and values, a php ini filename or null - */ - public static function globalVars ( $vars ) { - // Merge into the stack, overrides existing variables of the same name - if ( is_array( $vars ) ) { - self::$globalVars = array_merge( self::$globalVars, $vars ); - } - // Test for a file. If it is attempt to parse it - elseif ( is_string( $vars ) and file_exists( $vars ) ) { - if ( $result = parse_ini_file( $vars ) ) { - self::$globalVars = array_merge( self::$globalVars, $result ); - } - } - // Clear the stack if the argument is explicitly null - elseif ( is_null( $vars ) ) { - self::$globalVars = array(); - } - } - - /** - * Clear config file and compiled files for the specified directory - * - * @param string $dir System path to the directory - */ - public static function clearCache ( $dir = '' ) { - if ( empty( $dir ) ) { - $dir = dirname( __FILE__ ); - } - else if ( !file_exists( $dir ) ) { - return; - } - $configPath = $dir . '/' . self::$config->file; - if ( file_exists( $configPath ) ) { - unlink( $configPath ); - } - // Remove any compiled files - $suffix = self::$COMPILE_SUFFIX; - $suffixLength = strlen( $suffix ); - foreach ( scandir( $dir ) as $file ) { - if ( - strpos( $file, $suffix ) === strlen( $file ) - $suffixLength - ) { - unlink( $dir . "/{$file}" ); - } - } - } - - - ##################### - # Developer related - - public static $logging = false; - - public static function log () { - - if ( ! self::$logging ) { - return; - } - static $log = ''; - - $args = func_get_args(); - if ( !count( $args ) ) { - // No arguments, return the log - return $log; - } - else { - $arg = $args[0]; - } - if ( is_string( $arg ) ) { - $log .= $arg . '
'; - } - else { - $out = '
';
-			ob_start();
-			print_r( $arg );
-			$out .= ob_get_clean();
-			$out .= '
'; - $log .= $out . '
'; - } - } - - - ##################### - # Internal functions - - protected static function getHostfile ( $file = false ) { - // May return a hostfile object associated with a real file - // Alternatively it may return a hostfile object with string input - - $config = self::$config; - - // Make basic information about the hostfile accessible - $hostfile = new stdClass; - $hostfile->name = $file ? basename( $file ) : null; - $hostfile->dir = $config->baseDir; - $hostfile->path = $file ? "$config->baseDir/$hostfile->name" : null; - - if ( $file ) { - if ( !file_exists( $hostfile->path ) ) { - // If host file is not found return an empty string - trigger_error( __METHOD__ . ": File '$hostfile->name' not found.\n", E_USER_WARNING ); - return ''; - } - else { - // Capture the modified time - $hostfile->mtime = filemtime( $hostfile->path ); - } - } - return $hostfile; - } - - protected static function getBoilerplate () { - - $file = csscrush_util::find( 'CssCrush-local.boilerplate', 'CssCrush.boilerplate' ); - - if ( ! $file or ! self::$options[ 'boilerplate' ] ) { - return ''; - } - - // Load the file - $boilerplate = file_get_contents( $file ); - - // Process any tags, currently only '{{datetime}}' is supported - if ( preg_match_all( '!\{\{([^}]+)\}\}!', $boilerplate, $boilerplate_matches ) ) { - $replacements = array(); - foreach ( $boilerplate_matches[0] as $index => $tag ) { - if ( $boilerplate_matches[1][$index] === 'datetime' ) { - $replacements[] = @date( 'Y-m-d H:i:s O' ); - } - else { - $replacements[] = '?'; - } - } - $boilerplate = str_replace( $boilerplate_matches[0], $replacements, $boilerplate ); - } - // Pretty print - $boilerplate = explode( PHP_EOL, $boilerplate ); - $boilerplate = array_map( 'trim', $boilerplate ); - $boilerplate = array_map( create_function( '$it', 'return !empty($it) ? " $it" : $it;' ), $boilerplate ); - $boilerplate = implode( PHP_EOL . ' *', $boilerplate ); - return << false, - - // Append 'checksum' to output file name - 'versioning' => true, - - // Use the template boilerplate - 'boilerplate' => true, - - // Variables passed in at runtime - 'vars' => array(), - - // Enable/disable the cache - 'cache' => true, - - // Output file. Defaults the host-filename - 'output_file' => null, - - // Vendor target. Only apply prefixes for a specific vendor, set to 'none' for no prefixes - 'vendor_target' => 'all', - - // Whether to rewrite the url references inside imported files - // This will be 'true' by default eventually - 'rewrite_import_urls' => false, - - // Keeping track of global vars internally - '_globalVars' => self::$globalVars, - ); - - self::$options = is_array( $options ) ? - array_merge( $option_defaults, $options ) : $option_defaults; - - return self::$options; - } - - protected static function pruneAliases () { - - // If a vendor target is given, we prune the aliases array - $vendor = self::$options[ 'vendor_target' ]; - - // For expicit 'none' argument turn off aliases - if ( 'none' === $vendor ) { - self::$aliases = null; - return; - } - - // Default vendor argument, use all aliases as normal - if ( 'all' === $vendor ) { - return; - } - - // Normalize vendor_target argument - $vendor = str_replace( '-', '', self::$options[ 'vendor_target' ] ); - $vendor = "-$vendor-"; - - // Loop the aliases array, filter down to the target vendor - foreach ( self::$aliases as $group_name => $group_array ) { - // Property/value aliases are a special case - if ( 'values' === $group_name ) { - foreach ( $group_array as $property => $values ) { - $result = array(); - foreach ( $values as $value => $prefix_values ) { - foreach ( $prefix_values as $prefix ) { - if ( strpos( $prefix, $vendor ) === 0 ) { - $result[] = $prefix; - } - } - } - self::$aliases[ 'values' ][ $property ][ $value ] = $result; - } - continue; - } - foreach ( $group_array as $alias_keyword => $prefix_array ) { - $result = array(); - foreach ( $prefix_array as $prefix ) { - if ( strpos( $prefix, $vendor ) === 0 ) { - $result[] = $prefix; - } - } - // Prune the whole alias keyword if there is no result - if ( empty( $result ) ) { - unset( self::$aliases[ $group_name ][ $alias_keyword ] ); - } - else { - self::$aliases[ $group_name ][ $alias_keyword ] = $result; - } - } - } - // self::log( self::$aliases ); - } - - protected static function calculateVariables () { - - $regex = self::$regex; - - // In-file variables override global variables - // Runtime variables override in-file variables - self::$storage->variables = array_merge( - self::$globalVars, self::$storage->variables ); - if ( !empty( self::$options[ 'vars' ] ) ) { - self::$storage->variables = array_merge( - self::$storage->variables, self::$options[ 'vars' ] ); - } - - // Place variables referenced inside variables - // Excecute any custom functions - foreach ( self::$storage->variables as $name => &$value ) { - // Referenced variables - $value = preg_replace_callback( - $regex->function->var, array( 'self', 'cb_placeVariables' ), $value ); - - // Custom functions: - // Variable values can be escaped from function parsing with a double bang - if ( strpos( $value, '!!' ) === 0 ) { - $value = ltrim( $value, "!\t\r " ); - } - else { - $value = csscrush_function::parseAndExecuteValue( $value ); - } - } - } - - protected static function placeVariables ( $stream ) { - $stream = preg_replace_callback( - self::$regex->function->var, array( 'self', 'cb_placeVariables' ), $stream ); - // Place variables in any string tokens - foreach ( self::$storage->tokens->strings as $label => &$string ) { - if ( strpos( $string, '$' ) !== false ) { - $string = preg_replace_callback( - self::$regex->function->var, array( 'self', 'cb_placeVariables' ), $string ); - } - } - return $stream; - } - - protected static function reset () { - // Reset properties for current process - self::$tokenUID = 0; - self::$storage = new stdclass; - - self::$storage->tokens = (object) array( - 'strings' => array(), - 'comments' => array(), - 'rules' => array(), - 'parens' => array(), - ); - self::$storage->variables = array(); - // Temporary storage - self::$storage->tmp = new stdclass; - } - - protected static function compile ( $stream ) { - - $regex = self::$regex; - $options = self::$options; - - // Load in aliases and macros - if ( !self::$assetsLoaded ) { - self::loadAssets(); - self::$assetsLoaded = true; - } - - // Set aliases. May be pruned if a vendor target is set - self::$aliases = self::$aliasesRaw; - self::pruneAliases(); - - // Parse variables - $stream = self::extractVariables( $stream ); - - // Calculate the variable stack - self::calculateVariables(); - self::log( self::$storage->variables ); - - // Place the variables - $stream = self::placeVariables( $stream ); - - // Normalize whitespace - $stream = csscrush_util::normalizeWhiteSpace( $stream ); - - // Adjust the stream so we can extract the rules cleanly - $map = array( - '@' => "\n@", - '}' => "}\n", - '{' => "{\n", - ';' => ";\n", - ); - $stream = "\n" . str_replace( array_keys( $map ), array_values( $map ), $stream ); - - // Rules - $stream = self::extractAndProcessRules( $stream ); - - // Alias at-rules (if there are any) - $stream = self::aliasAtRules( $stream ); - - // print it all back - $stream = self::display( $stream ); - - // Add in boilerplate - if ( $options[ 'boilerplate' ] ) { - $stream = self::getBoilerplate() . "\n$stream"; - } - - self::log( self::$config->data ); - - // Release memory - self::$storage = null; - - return $stream; - } - - protected static function display ( $stream ) { - $minify = !self::$options[ 'debug' ]; - $regex = self::$regex; - - if ( $minify ) { - $stream = preg_replace( $regex->token->comment, '', $stream ); - } - else { - // Create newlines after tokens - $stream = preg_replace( '!([{}])!', "$1\n", $stream ); - $stream = preg_replace( '!([@])!', "\n$1", $stream ); - $stream = preg_replace( '!(___[a-z0-9]+___)!', "$1\n", $stream ); - - // Kill double spaces - $stream = ltrim( preg_replace( '!\n+!', "\n", $stream ) ); - } - - // Kill leading space - $stream = preg_replace( '!\n\s+!', "\n", $stream ); - - // Print out rules - $stream = preg_replace_callback( $regex->token->rule, array( 'self', 'cb_printRule' ), $stream ); - - // Insert parens - $paren_labels = array_keys( self::$storage->tokens->parens ); - $paren_values = array_values( self::$storage->tokens->parens ); - $stream = str_replace( $paren_labels, $paren_values, $stream ); - - if ( $minify ) { - $stream = self::minify( $stream ); - } - else { - // Insert comments - $comment_labels = array_keys( self::$storage->tokens->comments ); - $comment_values = array_values( self::$storage->tokens->comments ); - foreach ( $comment_values as &$comment ) { - $comment = "$comment\n"; - } - $stream = str_replace( $comment_labels, $comment_values, $stream ); - // Normalize line breaks - $stream = preg_replace( '!\n{3,}!', "\n\n", $stream ); - } - - // Insert literals - $string_labels = array_keys( self::$storage->tokens->strings ); - $string_values = array_values( self::$storage->tokens->strings ); - $stream = str_replace( $string_labels, $string_values, $stream ); - - // I think we're done - return $stream; - } - - protected static function validateCache ( $hostfile ) { - $config = self::$config; - - // Search base directory for an existing compiled file - foreach ( scandir( $config->baseDir ) as $filename ) { - - if ( self::$compileName != $filename ) { - continue; - } - // Cached file exists - self::log( 'Cached file exists' ); - - $existingfile = new stdClass; - $existingfile->name = $filename; - $existingfile->path = "$config->baseDir/$existingfile->name"; - $existingfile->URL = "$config->baseURL/$existingfile->name"; - - // Start off with the host file then add imported files - $all_files = array( $hostfile->mtime ); - - if ( file_exists( $existingfile->path ) and isset( $config->data[ self::$compileName ] ) ) { - // File exists and has config - self::log( 'has config' ); - foreach ( $config->data[ $existingfile->name ][ 'imports' ] as $import_file ) { - // Check if this is docroot relative or hostfile relative - $root = strpos( $import_file, '/' ) === 0 ? $config->docRoot : $config->baseDir; - $import_filepath = realpath( $root ) . "/{$import_file}"; - if ( file_exists( $import_filepath ) ) { - $all_files[] = filemtime( $import_filepath ); - } - else { - // File has been moved, remove old file and skip to compile - self::log( 'Import file has been moved, removing existing file' ); - unlink( $existingfile->path ); - return false; - } - } - - $existing_options = $config->data[ $existingfile->name ][ 'options' ]; - $existing_datesum = $config->data[ $existingfile->name ][ 'datem_sum' ]; - if ( - $existing_options == self::$options and - $existing_datesum == array_sum( $all_files ) - ) { - // Files have not been modified and config is the same: return the old file - self::log( "Files and options have not been modified, returning existing - file '$existingfile->URL'" ); - return $existingfile->URL . ( self::$options[ 'versioning' ] !== false ? "?{$existing_datesum}" : '' ); - } - else { - // Remove old file and continue making a new one... - self::log( 'Files or options have been modified, removing existing file' ); - unlink( $existingfile->path ); - } - } - else if ( file_exists( $existingfile->path ) ) { - // File exists but has no config - self::log( 'File exists but no config, removing existing file' ); - unlink( $existingfile->path ); - } - return false; - - } // foreach - return false; - } - - protected static function minify ( $str ) { - $replacements = array( - '!\n+| (\{)!' => '$1', // Trim whitespace - '!(^|[: \(,])0(\.\d+)!' => '$1$2', // Strip leading zeros on floats - '!(^|[: \(,])\.?0[a-zA-Z]{1,5}!i' => '${1}0', // Strip unnecessary units on zero values - '!(^|\:) *(0 0 0|0 0 0 0) *(;|\})!' => '${1}0${3}', // Collapse zero lists - '!(padding|margin) ?\: *0 0 *(;|\})!' => '${1}:0${2}', // Collapse zero lists continued - '!\s*([>~+=])\s*!' => '$1', // Clean-up around combinators - '!\#([0-9a-f])\1([0-9a-f])\2([0-9a-f])\3!i' - => '#$1$2$3', // Compress hex codes - ); - return preg_replace( - array_keys( $replacements ), array_values( $replacements ), $str ); - } - - protected static function aliasAtRules ( $stream ) { - - if ( empty( self::$aliases[ 'at-rules' ] ) ) { - return $stream; - } - - $aliases = self::$aliases[ 'at-rules' ]; - - foreach ( $aliases as $at_rule => $at_rule_aliases ) { - if ( - strpos( $stream, "@$at_rule " ) === -1 or - strpos( $stream, "@$at_rule{" ) === -1 - ) { - // Nothing to see here - continue; - } - $scan_pos = 0; - - // Find at-rules that we want to alias - while ( preg_match( "!@$at_rule" . '[\s{]!', $stream, $match, PREG_OFFSET_CAPTURE, $scan_pos ) ) { - - // Store the match position - $block_start_pos = $match[0][1]; - // Capture the curly bracketed block - $curly_match = csscrush_util::matchBrackets( $stream, $brackets = array( '{', '}' ), $block_start_pos ); - - if ( !$curly_match ) { - // Couldn't match the block - break; - } - - // The end of the block - $block_end_pos = $curly_match->end; - - // Build up string with aliased blocks for splicing - $original_block = substr( $stream, $block_start_pos, $block_end_pos - $block_start_pos ); - $blocks = array(); - foreach ( $at_rule_aliases as $alias ) { - // Copy original block, replacing at-rule with alias name - $copy_block = str_replace( "@$at_rule", "@$alias", $original_block ); - - // Aliases are nearly always prefixed, capture the current vendor name - preg_match( self::$regex->vendorPrefix, $alias, $vendor ); - - $vendor = $vendor ? $vendor[1] : null; - - // Duplicate rules - if ( preg_match_all( self::$regex->token->rule, $copy_block, $copy_matches ) ) { - $originals = array(); - $replacements = array(); - - foreach ( $copy_matches[0] as $copy_match ) { - // Clone the matched rule - $originals[] = $rule_label = $copy_match; - $cloneRule = clone self::$storage->tokens->rules[ $rule_label ]; - - // Set the vendor context - $cloneRule->vendorContext = $vendor; - - // Filter out declarations that have different vendor context - $new_set = array(); - foreach ( $cloneRule as $declaration ) { - if ( !$declaration->vendor or $declaration->vendor === $vendor ) { - $new_set[] = $declaration; - } - } - $cloneRule->declarations = $new_set; - - // Store the clone - $replacements[] = $clone_rule_label = self::createTokenLabel( 'r' ); - self::$storage->tokens->rules[ $clone_rule_label ] = $cloneRule; - } - // Finally replace the original labels with the cloned rule labels - $copy_block = str_replace( $originals, $replacements, $copy_block ); - } - $blocks[] = $copy_block; - } - - // The original version is always last in the list - $blocks[] = $original_block; - $blocks = implode( "\n", $blocks ); - - // Glue back together - $stream = - substr( $stream, 0, $block_start_pos ) . - $blocks . - substr( $stream, $block_end_pos ); - - // Move the regex pointer forward - $scan_pos = $block_start_pos + strlen( $blocks ); - - } // while - - } // foreach - return $stream; - } - - public static function createTokenLabel ( $prefix, $counter = null ) { - $counter = !is_null( $counter ) ? $counter : ++self::$tokenUID; - return "___$prefix{$counter}___"; - } - - - ############################# - # preg_replace callbacks - - protected static function cb_extractStrings ( $match ) { - $label = csscrush::createTokenLabel( 's' ); - csscrush::$storage->tokens->strings[ $label ] = $match[0]; - return $label; - } - - protected static function cb_restoreStrings ( $match ) { - return csscrush::$storage->tokens->strings[ $match[0] ]; - } - - protected static function cb_extractComments ( $match ) { - - $comment = $match[0]; - - // Strip private comments - $private_comment_marker = '$!'; - if ( strpos( $comment, '/*' . $private_comment_marker ) === 0 ) { - return ''; - } - - $label = self::createTokenLabel( 'c' ); - self::$storage->tokens->comments[ $label ] = $comment; - - return $label; - } - - protected static function cb_restoreComments ( $match ) { - return self::$storage->tokens->comments[ $match[0] ]; - } - - protected static function cb_extractVariables ( $match ) { - $regex = self::$regex; - - $block = $match[2]; - - // Strip comment markers - $block = preg_replace( $regex->token->comment, '', $block ); - - // Need to split safely as there are semi-colons in data-uris - $variables_match = csscrush_util::splitDelimList( $block, ';', true ); - - // Loop through the pairs, restore parens - foreach ( $variables_match->list as $var ) { - $colon = strpos( $var, ':' ); - if ( $colon === -1 ) { - continue; - } - $name = trim( substr( $var, 0, $colon ) ); - $value = trim( substr( $var, $colon + 1 ) ); - self::$storage->variables[ trim( $name ) ] = $value; - } - return ''; - } - - protected static function cb_placeVariables ( $match ) { - $before_char = $match[1]; - - // Check for dollar shorthand - if ( empty( $match[2] ) and isset( $match[3] ) and strpos( $match[0], '$' ) !== false ) { - $variable_name = $match[3]; - } - else { - $variable_name = $match[2]; - } - - if ( isset( self::$storage->variables[ $variable_name ] ) ) { - return $before_char . self::$storage->variables[ $variable_name ]; - } - else { - return $before_char; - } - } - - protected static function cb_extractAndProcessRules ( $match ) { - - $rule = new stdClass; - $rule->selector_raw = $match[1]; - $rule->declaration_raw = $match[2]; - - csscrush_hook::run( 'rule_preprocess', $rule ); - - $rule = new csscrush_rule( $rule->selector_raw, $rule->declaration_raw ); - - // Only store rules with declarations - if ( !empty( $rule->declarations ) ) { - - csscrush_hook::run( 'rule_prealias', $rule ); - - if ( !empty( self::$aliases ) ) { - $rule->addPropertyAliases(); - $rule->addFunctionAliases(); - $rule->addValueAliases(); - } - - csscrush_hook::run( 'rule_postalias', $rule ); - - $rule->expandSelectors(); - - csscrush_hook::run( 'rule_postprocess', $rule ); - - $label = self::createTokenLabel( 'r' ); - self::$storage->tokens->rules[ $label ] = $rule; - return $label . "\n"; - } - else { - return ''; - } - } - - protected static function cb_restoreLiteral ( $match ) { - return self::$storage->tokens[ $match[0] ]; - } - - protected static function cb_printRule ( $match ) { - $minify = !self::$options[ 'debug' ]; - $ruleLabel = $match[0]; - if ( !isset( self::$storage->tokens->rules[ $ruleLabel ] ) ) { - return ''; - } - $rule = self::$storage->tokens->rules[ $ruleLabel ]; - - // Build the selector - $selectors = implode( ',', $rule->selectors ); - - // Build the block - $block = array(); - $colon = $minify ? ':' : ': '; - foreach ( $rule as $declaration ) { - $block[] = "{$declaration->property}$colon{$declaration->value}"; - } - - // Return whole rule - if ( $minify ) { - $block = implode( ';', $block ); - return "$selectors{{$block}}"; - } - else { - $block = implode( ";\n\t", $block ); - // Include pre rule comments - $comments = implode( "\n", $rule->comments ); - return "$comments\n$selectors {\n\t$block;\n\t}\n"; - } - } - - - ############ - # Parsing methods - - public static function extractAndProcessRules ( $stream ) { - return preg_replace_callback( self::$regex->rule, array( 'self', 'cb_extractAndProcessRules' ), $stream ); - } - - public static function extractVariables ( $stream ) { - return preg_replace_callback( self::$regex->variables, array( 'self', 'cb_extractVariables' ), $stream ); - } - - public static function extractComments ( $stream ) { - return preg_replace_callback( self::$regex->comment, array( 'self', 'cb_extractComments' ), $stream ); - } - - public static function extractStrings ( $stream ) { - return preg_replace_callback( self::$regex->string, array( 'self', 'cb_extractStrings' ), $stream ); - } - -} - - -####################### -# Procedural style API - -function csscrush_file ( $file, $options = null ) { - return csscrush::file( $file, $options ); -} -function csscrush_tag ( $file, $options = null, $attributes = array() ) { - return csscrush::tag( $file, $options, $attributes ); -} -function csscrush_inline ( $file, $options = null, $attributes = array() ) { - return csscrush::inline( $file, $options, $attributes ); -} -function csscrush_string ( $string, $options = null ) { - return csscrush::string( $string, $options ); -} -function csscrush_globalvars ( $vars ) { - return csscrush::globalVars( $vars ); -} -function csscrush_clearcache ( $dir = '' ) { - return csscrush::clearcache( $dir ); -} - - diff --git a/lib/CssCrush/BalancedMatch.php b/lib/CssCrush/BalancedMatch.php new file mode 100644 index 0000000..05e455d --- /dev/null +++ b/lib/CssCrush/BalancedMatch.php @@ -0,0 +1,75 @@ +string = $string; + $this->offset = $offset; + $this->match = null; + $this->length = 0; + + list($opener, $closer) = str_split($brackets, 1); + + if (strpos($string->raw, $opener, $this->offset) === false) { + + return; + } + + if (substr_count($string->raw, $opener) !== substr_count($string->raw, $closer)) { + $sample = substr($string->raw, $this->offset, 25); + warning("Unmatched token near '$sample'."); + + return; + } + + $patt = ($opener === '{') ? Regex::$patt->block : Regex::$patt->parens; + + if (preg_match($patt, $string->raw, $m, PREG_OFFSET_CAPTURE, $this->offset)) { + + $this->match = $m; + $this->matchLength = strlen($m[0][0]); + $this->matchStart = $m[0][1]; + $this->matchEnd = $this->matchStart + $this->matchLength; + $this->length = $this->matchEnd - $this->offset; + } + else { + warning("Could not match '$opener'. Exiting."); + } + } + + public function inside() + { + return $this->match[2][0]; + } + + public function whole() + { + return substr($this->string->raw, $this->offset, $this->length); + } + + public function replace($replacement) + { + $this->string->splice($replacement, $this->offset, $this->length); + } + + public function unWrap() + { + $this->string->splice($this->inside(), $this->offset, $this->length); + } +} diff --git a/lib/CssCrush/Collection.php b/lib/CssCrush/Collection.php new file mode 100644 index 0000000..d811e0f --- /dev/null +++ b/lib/CssCrush/Collection.php @@ -0,0 +1,74 @@ +store = $store; + } + + public function get($index = null) + { + return is_int($index) ? $this->store[$index] : $this->store; + } + + static public function value($item, $property) + { + if (strpos($property, '|') !== false) { + $filters = explode('|', $property); + $property = array_shift($filters); + $value = $item->$property; + foreach ($filters as $filter) { + switch ($filter) { + case 'lower': + $value = strtolower($value); + break; + } + } + return $value; + } + return $item->$property; + } + + public function filter($filterer, $op = '===') + { + if (is_array($filterer)) { + + $ops = [ + '===' => function ($item) use ($filterer) { + foreach ($filterer as $property => $value) { + if (Collection::value($item, $property) !== $value) { + return false; + } + } + return true; + }, + '!==' => function ($item) use ($filterer) { + foreach ($filterer as $property => $value) { + if (Collection::value($item, $property) === $value) { + return false; + } + } + return true; + }, + ]; + + $callback = $ops[$op]; + } + elseif (is_callable($filterer)) { + $callback = $filterer; + } + + if (isset($callback)) { + $this->store = array_filter($this->store, $callback); + } + + return $this; + } +} diff --git a/lib/CssCrush/Color.php b/lib/CssCrush/Color.php new file mode 100644 index 0000000..048ddda --- /dev/null +++ b/lib/CssCrush/Color.php @@ -0,0 +1,472 @@ + $rgb) { + $namedColors[$name] = array_map('floatval', explode(',', $rgb)) + [0, 0, 0, 1]; + } + } + } + + return isset(Crush::$process->colorKeywords) ? Crush::$process->colorKeywords : $namedColors; + } + + public static function getMinifyableKeywords() + { + if (! isset(self::$minifyableKeywords)) { + + // If color name is longer than 4 and less than 8 test to see if its hex + // representation could be shortened. + $keywords = self::getKeywords(); + + foreach ($keywords as $name => $rgba) { + $name_len = strlen($name); + if ($name_len < 5) { + continue; + } + + $hex = self::rgbToHex($rgba); + + if ($name_len > 7) { + self::$minifyableKeywords[$name] = $hex; + } + else { + if (preg_match(Regex::$patt->cruftyHex, $hex)) { + self::$minifyableKeywords[$name] = $hex; + } + } + } + } + + return self::$minifyableKeywords; + } + + public static function parse($str) + { + if ($test = Color::test($str)) { + $color = $test['value']; + $type = $test['type']; + } + else { + + return false; + } + + $rgba = false; + + switch ($type) { + + case 'hex': + $rgba = Color::hexToRgb($color); + break; + + case 'rgb': + case 'rgba': + case 'hsl': + case 'hsla': + $function = $type; + $vals = substr($color, strlen($function) + 1); // Trim function name and start paren. + $vals = substr($vals, 0, strlen($vals) - 1); // Trim end paren. + $vals = array_map('trim', explode(',', $vals)); // Explode to array of arguments. + + // Always set the alpha channel. + $vals[3] = isset($vals[3]) ? floatval($vals[3]) : 1; + + if (strpos($function, 'rgb') === 0) { + $rgba = Color::normalizeCssRgb($vals); + } + else { + $rgba = Color::cssHslToRgb($vals); + } + break; + + case 'keyword': + $keywords = self::getKeywords(); + $rgba = $keywords[$color]; + break; + } + + return $rgba; + } + + public static function test($str) + { + static $color_patt; + if (! $color_patt) { + $color_patt = Regex::make('~^( + \#(?={{hex}}{3}) | + \#(?={{hex}}{6}) | + rgba?(?=\() | + hsla?(?=\() + )~ixS'); + } + + $color_test = []; + $str = strtolower(trim($str)); + + // First match a hex value or the start of a function. + if (preg_match($color_patt, $str, $m)) { + + $type_match = $m[1]; + + switch ($type_match) { + case '#': + $color_test['type'] = 'hex'; + break; + + case 'hsl': + case 'hsla': + case 'rgb': + case 'rgba': + $color_test['type'] = $type_match; + break; + } + } + + // Secondly try to match a color keyword. + else { + $keywords = self::getKeywords(); + if (isset($keywords[$str])) { + $color_test['type'] = 'keyword'; + } + } + + if ($color_test) { + $color_test['value'] = $str; + } + + return $color_test ? $color_test : false; + } + + /** + * http://mjijackson.com/2008/02/ + * rgb-to-hsl-and-rgb-to-hsv-color-model-conversion-algorithms-in-javascript + * + * Converts an RGB color value to HSL. Conversion formula + * adapted from http://en.wikipedia.org/wiki/HSL_color_space. + * Assumes r, g, and b are contained in the set [0, 255] and + * returns h, s, and l in the set [0, 1]. + */ + public static function rgbToHsl(array $rgba) + { + list($r, $g, $b, $a) = $rgba; + $r /= 255; + $g /= 255; + $b /= 255; + $max = max($r, $g, $b); + $min = min($r, $g, $b); + $h = 0; + $s = 0; + $l = ($max + $min) / 2; + + if ($max == $min) { + $h = $s = 0; + } + else { + $d = $max - $min; + $s = $l > 0.5 ? $d / (2 - $max - $min) : $d / ($max + $min); + switch($max) { + case $r: + $h = ($g - $b) / $d + ($g < $b ? 6 : 0); + break; + case $g: + $h = ($b - $r) / $d + 2; + break; + case $b: + $h = ($r - $g) / $d + 4; + break; + } + $h /= 6; + } + + return [$h, $s, $l, $a]; + } + + /** + * http://mjijackson.com/2008/02/ + * rgb-to-hsl-and-rgb-to-hsv-color-model-conversion-algorithms-in-javascript + * + * Converts an HSL color value to RGB. Conversion formula + * adapted from http://en.wikipedia.org/wiki/HSL_color_space. + * Assumes h, s, and l are contained in the set [0, 1] and + * returns r, g, and b in the set [0, 255]. + */ + public static function hslToRgb(array $hsla) + { + // Populate unspecified alpha value. + if (! isset($hsla[3])) { + $hsla[3] = 1; + } + + list($h, $s, $l, $a) = $hsla; + $r = 0; + $g = 0; + $b = 0; + if ($s == 0) { + $r = $g = $b = $l; + } + else { + $q = $l < 0.5 ? $l * (1 + $s) : $l + $s - $l * $s; + $p = 2 * $l - $q; + $r = self::hueToRgb($p, $q, $h + 1 / 3); + $g = self::hueToRgb($p, $q, $h); + $b = self::hueToRgb($p, $q, $h - 1 / 3); + } + + return [round($r * 255), round($g * 255), round($b * 255), $a]; + } + + // Convert percentages to points (0-255). + public static function normalizeCssRgb(array $rgba) + { + foreach ($rgba as &$val) { + if (strpos($val, '%') !== false) { + $val = str_replace('%', '', $val); + $val = round($val * 2.55); + } + } + + return $rgba; + } + + public static function cssHslToRgb(array $hsla) + { + // Populate unspecified alpha value. + if (! isset($hsla[3])) { + $hsla[3] = 1; + } + + // Alpha is carried over. + $a = array_pop($hsla); + + // Normalize the hue degree value then convert to float. + $h = array_shift($hsla); + $h = $h % 360; + if ($h < 0) { + $h = 360 + $h; + } + $h = $h / 360; + + // Convert saturation and lightness to floats. + foreach ($hsla as &$val) { + $val = str_replace('%', '', $val); + $val /= 100; + } + list($s, $l) = $hsla; + + return self::hslToRgb([$h, $s, $l, $a]); + } + + public static function hueToRgb($p, $q, $t) + { + if ($t < 0) $t += 1; + if ($t > 1) $t -= 1; + if ($t < 1/6) return $p + ($q - $p) * 6 * $t; + if ($t < 1/2) return $q; + if ($t < 2/3) return $p + ($q - $p) * (2 / 3 - $t) * 6; + return $p; + } + + public static function rgbToHex(array $rgba) + { + // Drop alpha component. + if (isset($rgba[3])) { + array_pop($rgba); + } + + $hex_out = '#'; + foreach ($rgba as $val) { + $hex_out .= str_pad(dechex($val), 2, '0', STR_PAD_LEFT); + } + + return $hex_out; + } + + public static function hexToRgb($hex) + { + $hex = substr($hex, 1); + + // Handle shortened format. + if (strlen($hex) === 3) { + $long_hex = []; + foreach (str_split($hex) as $val) { + $long_hex[] = $val . $val; + } + $hex = $long_hex; + } + else { + $hex = str_split($hex, 2); + } + + // Return RGBa + $rgba = array_map('hexdec', $hex); + $rgba[] = 1; + + return $rgba; + } + + public static function colorAdjust($str, array $adjustments) + { + $hsla = new Color($str, true); + + // On failure to parse return input. + return $hsla->isValid ? $hsla->adjust($adjustments)->__toString() : $str; + } + + public static function colorSplit($str) + { + if ($test = Color::test($str)) { + $color = $test['value']; + $type = $test['type']; + } + else { + + return false; + } + + // If non-alpha color return early. + if (! in_array($type, ['hsla', 'rgba'])) { + + return [$color, 1]; + } + + // Strip all whitespace. + $color = preg_replace('~\s+~', '', $color); + + // Extract alpha component if one is matched. + $opacity = 1; + if (preg_match( + Regex::make('~^(rgb|hsl)a\(({{number}}%?,{{number}}%?,{{number}}%?),({{number}})\)$~i'), + $color, + $m) + ) { + $opacity = floatval($m[3]); + $color = "$m[1]($m[2])"; + } + + return [$color, $opacity]; + } + + + ############################# + # Instances. + + protected $value; + protected $hslColorSpace; + protected $namedComponents = [ + 'red' => 0, + 'green' => 1, + 'blue' => 2, + 'alpha' => 3, + ]; + public $isValid; + + public function __construct($color, $useHslColorSpace = false) + { + $this->value = is_array($color) ? $color : self::parse($color); + $this->isValid = ! empty($this->value); + if ($useHslColorSpace && $this->isValid) { + $this->toHsl(); + } + } + + public function __toString() + { + // For opaque colors return hex notation as it's the most compact. + if ($this->getComponent('alpha') == 1) { + + return $this->getHex(); + } + + // R, G and B components must be integers. + $components = []; + foreach (($this->hslColorSpace ? $this->getRgb() : $this->value) as $index => $component) { + $components[] = ($index === 3) ? $component : min(round($component), 255); + } + + return 'rgba(' . implode(',', $components) . ')'; + } + + public function toRgb() + { + if ($this->hslColorSpace) { + $this->hslColorSpace = false; + $this->value = self::hslToRgb($this->value); + } + + return $this; + } + + public function toHsl() + { + if (! $this->hslColorSpace) { + $this->hslColorSpace = true; + $this->value = self::rgbToHsl($this->value); + } + + return $this; + } + + public function getHex() + { + return self::rgbToHex($this->getRgb()); + } + + public function getHsl() + { + return ! $this->hslColorSpace ? self::rgbToHsl($this->value) : $this->value; + } + + public function getRgb() + { + return $this->hslColorSpace ? self::hslToRgb($this->value) : $this->value; + } + + public function getComponent($index) + { + $index = isset($this->namedComponents[$index]) ? $this->namedComponents[$index] : $index; + return $this->value[$index]; + } + + public function setComponent($index, $newComponentValue) + { + $index = isset($this->namedComponents[$index]) ? $this->namedComponents[$index] : $index; + $this->value[$index] = is_numeric($newComponentValue) ? $newComponentValue : 0; + } + + public function adjust(array $adjustments) + { + $wasHslColor = $this->hslColorSpace; + + $this->toHsl(); + + // Normalize percentage adjustment parameters to floating point numbers. + foreach ($adjustments as $index => $val) { + + // Normalize argument. + $val = $val ? trim(str_replace('%', '', $val)) : 0; + + if ($val) { + // Reduce value to float. + $val /= 100; + // Update the color component. + $this->setComponent($index, max(0, min(1, $this->getComponent($index) + $val))); + } + } + + return ! $wasHslColor ? $this->toRgb() : $this; + } +} diff --git a/lib/CssCrush/Crush.php b/lib/CssCrush/Crush.php new file mode 100644 index 0000000..0c1348e --- /dev/null +++ b/lib/CssCrush/Crush.php @@ -0,0 +1,293 @@ +pluginDirs = [self::$dir . '/plugins']; + self::$config->scriptDir = dirname(realpath($_SERVER['SCRIPT_FILENAME'])); + self::$config->docRoot = self::resolveDocRoot(); + self::$config->logger = new Logger(); + self::$config->io = 'CssCrush\IO'; + + // Shared resources. + self::$config->vars = []; + self::$config->aliasesFile = self::$dir . '/aliases.ini'; + self::$config->aliases = []; + self::$config->bareAliases = [ + 'properties' => [], + 'functions' => [], + 'function_groups' => [], + 'declarations' => [], + 'at-rules' => [], + ]; + self::$config->options = new Options(); + + require_once self::$dir . '/misc/formatters.php'; + } + + static protected function resolveDocRoot($doc_root = null) + { + // Get document_root reference + // $_SERVER['DOCUMENT_ROOT'] is unreliable in certain CGI/Apache/IIS setups + + if (! $doc_root) { + + $script_filename = $_SERVER['SCRIPT_FILENAME']; + $script_name = $_SERVER['SCRIPT_NAME']; + + if ($script_filename && $script_name) { + + $len_diff = strlen($script_filename) - strlen($script_name); + + // We're comparing the two strings so normalize OS directory separators + $script_filename = str_replace('\\', '/', $script_filename); + $script_name = str_replace('\\', '/', $script_name); + + // Check $script_filename ends with $script_name + if (substr($script_filename, $len_diff) === $script_name) { + + $path = substr($script_filename, 0, $len_diff); + $doc_root = realpath($path); + } + } + + if (! $doc_root) { + $doc_root = realpath($_SERVER['DOCUMENT_ROOT']); + } + + if (! $doc_root) { + warning("Could not get a valid DOCUMENT_ROOT reference."); + } + } + + return Util::normalizePath($doc_root); + } + + public static function loadAssets() + { + static $called; + if ($called) { + return; + } + $called = true; + + if (! self::$config->aliases) { + $aliases = self::parseAliasesFile(self::$config->aliasesFile); + self::$config->aliases = $aliases ?: self::$config->bareAliases; + } + } + + public static function plugin($name = null, ?callable $callback = null) + { + static $plugins = []; + + if (! $callback) { + return isset($plugins[$name]) ? $plugins[$name] : null; + } + + $plugins[$name] = $callback; + } + + public static function enablePlugin($name) + { + $plugin = self::plugin($name); + if (! $plugin) { + $path = self::$dir . "/plugins/$name.php"; + if (! file_exists($path)) { + notice("Plugin '$name' not found."); + return; + } + require_once $path; + $plugin = self::plugin($name); + } + + $plugin(self::$process); + } + + public static function parseAliasesFile($file) + { + if (! ($tree = Util::parseIni($file, true))) { + + return false; + } + + $regex = Regex::$patt; + + // Some alias groups need further parsing to unpack useful information into the tree. + foreach ($tree as $section => $items) { + + if ($section === 'declarations') { + + $store = []; + foreach ($items as $prop_val => $aliases) { + + list($prop, $value) = array_map('trim', explode(':', $prop_val)); + + foreach ($aliases as &$alias) { + + list($p, $v) = explode(':', $alias); + $vendor = null; + + // Try to detect the vendor from property and value in turn. + if ( + preg_match($regex->vendorPrefix, $p, $m) + || preg_match($regex->vendorPrefix, $v, $m) + ) { + $vendor = $m[1]; + } + $alias = [$p, $v, $vendor]; + } + $store[$prop][$value] = $aliases; + } + $tree['declarations'] = $store; + } + + // Function groups. + elseif (strpos($section, 'functions.') === 0) { + + $group = substr($section, strlen('functions')); + + $vendor_grouped_aliases = []; + foreach ($items as $func_name => $aliases) { + + // Assign group name to the aliasable function. + $tree['functions'][$func_name] = $group; + + foreach ($aliases as $alias_func) { + + // Only supporting vendor prefixed aliases, for now. + if (preg_match($regex->vendorPrefix, $alias_func, $m)) { + + // We'll cache the function matching regex here. + $vendor_grouped_aliases[$m[1]]['find'][] = Regex::make("~{{ LB }}$func_name(?=\()~iS"); + $vendor_grouped_aliases[$m[1]]['replace'][] = $alias_func; + } + } + } + $tree['function_groups'][$group] = $vendor_grouped_aliases; + unset($tree[$section]); + } + } + + $tree += self::$config->bareAliases; + + // Persisting dummy aliases for testing purposes. + $tree['properties']['foo'] = + $tree['at-rules']['foo'] = + $tree['functions']['foo'] = ['-webkit-foo', '-moz-foo', '-ms-foo']; + + return $tree; + } + + ############################# + # Logging and stats. + + public static function printLog() + { + if (! empty(self::$process->debugLog)) { + + if (PHP_SAPI !== 'cli') { + $out = []; + foreach (self::$process->debugLog as $item) { + $out[] = '
' . htmlspecialchars($item) . '
'; + } + echo implode('
', $out); + } + else { + echo implode(PHP_EOL, self::$process->debugLog), PHP_EOL; + } + } + } + + public static function runStat() + { + $process = Crush::$process; + + foreach (func_get_args() as $stat_name) { + + switch ($stat_name) { + case 'paths': + $process->stat['input_filename'] = $process->input->filename; + $process->stat['input_path'] = $process->input->path; + $process->stat['output_filename'] = $process->output->filename; + $process->stat['output_path'] = $process->output->dir . '/' . $process->output->filename; + break; + + case 'vars': + $process->stat['vars'] = array_map(function ($item) use ($process) { + return $process->tokens->restore($process->functions->apply($item), ['s', 'u', 'p']); + }, $process->vars); + break; + + case 'compile_time': + $process->stat['compile_time'] = microtime(true) - $process->stat['compile_start_time']; + unset($process->stat['compile_start_time']); + break; + + case 'selector_count': + $process->stat['selector_count'] = 0; + foreach ($process->tokens->store->r as $rule) { + $process->stat['selector_count'] += count($rule->selectors); + } + break; + + case 'rule_count': + $process->stat['rule_count'] = count($process->tokens->store->r); + break; + } + } + } +} + +function warning($message, $context = []) { + Crush::$process->errors[] = $message; + $logger = Crush::$config->logger; + if ($logger instanceof Logger) { + $message = "[CssCrush] $message"; + } + $logger->warning($message, $context); +} + +function notice($message, $context = []) { + Crush::$process->warnings[] = $message; + $logger = Crush::$config->logger; + if ($logger instanceof Logger) { + $message = "[CssCrush] $message"; + } + $logger->notice($message, $context); +} + +function debug($message, $context = []) { + Crush::$config->logger->debug($message, $context); +} + +function log($message, $context = [], $type = 'debug') { + Crush::$config->logger->$type($message, $context); +} + +// Compat with PHP < 7.2. +if (! defined('PREG_UNMATCHED_AS_NULL')) { + define('PREG_UNMATCHED_AS_NULL', null); +} + +Crush::init(); diff --git a/lib/CssCrush/Declaration.php b/lib/CssCrush/Declaration.php new file mode 100644 index 0000000..34cdf18 --- /dev/null +++ b/lib/CssCrush/Declaration.php @@ -0,0 +1,135 @@ +custom = true; + $this->skip = true; + } + else { + $property = strtolower($property); + } + + if ($this->skip = strpos($property, '~') === 0) { + $property = substr($property, 1); + } + + // Store the canonical property name. + // Store the vendor mark if one is present. + if (preg_match(Regex::$patt->vendorPrefix, $property, $vendor)) { + $canonical_property = $vendor[2]; + $vendor = $vendor[1]; + } + else { + $vendor = null; + $canonical_property = $property; + } + + // Check for !important. + if (($important = stripos($value, '!important')) !== false) { + $value = rtrim(substr($value, 0, $important)); + $this->important = true; + } + + Crush::$process->emit('declaration_preprocess', ['property' => &$property, 'value' => &$value]); + + // Reject declarations with empty CSS values. + if ($value === false || $value === '') { + $this->valid = false; + } + + $this->property = $property; + $this->canonicalProperty = $canonical_property; + $this->vendor = $vendor; + $this->index = $contextIndex; + $this->value = $value; + } + + public function __toString() + { + if (Crush::$process->minifyOutput) { + $whitespace = ''; + } + else { + $whitespace = ' '; + } + $important = $this->important ? "$whitespace!important" : ''; + + return "$this->property:$whitespace$this->value$important"; + } + + /* + Execute functions on value. + Index functions. + */ + public function process($parentRule) + { + static $thisFunction; + if (! $thisFunction) { + $thisFunction = new Functions(['this' => 'CssCrush\fn__this']); + } + + if (! $this->skip) { + + // this() function needs to be called exclusively because it is self referencing. + $context = (object) [ + 'rule' => $parentRule + ]; + $this->value = $thisFunction->apply($this->value, $context); + + if (isset($parentRule->declarations->data)) { + $parentRule->declarations->data += [$this->property => $this->value]; + } + + $context = (object) [ + 'rule' => $parentRule, + 'property' => $this->property + ]; + $this->value = Crush::$process->functions->apply($this->value, $context); + } + + // Whitespace may have been introduced by functions. + $this->value = trim($this->value); + + if ($this->value === '') { + $this->valid = false; + return; + } + + $parentRule->declarations->queryData[$this->property] = $this->value; + + $this->indexFunctions(); + } + + public function indexFunctions() + { + // Create an index of all regular functions in the value. + $functions = []; + if (preg_match_all(Regex::$patt->functionTest, $this->value, $m)) { + foreach ($m['func_name'] as $fn_name) { + $functions[strtolower($fn_name)] = true; + } + } + $this->functions = $functions; + } +} diff --git a/lib/CssCrush/DeclarationList.php b/lib/CssCrush/DeclarationList.php new file mode 100644 index 0000000..1dad737 --- /dev/null +++ b/lib/CssCrush/DeclarationList.php @@ -0,0 +1,598 @@ +rule = $rule; + $pairs = DeclarationList::parse($declarationsString); + + foreach ($pairs as $index => $pair) { + + list($prop, $value) = $pair; + + // Directives. + if ($prop === 'extends') { + $this->rule->addExtendSelectors($value); + unset($pairs[$index]); + } + elseif ($prop === 'name') { + if (! $this->rule->name) { + $this->rule->name = $value; + } + unset($pairs[$index]); + } + } + + // Build declaration list. + foreach ($pairs as $index => &$pair) { + + list($prop, $value) = $pair; + + if (trim($value) !== '') { + + if ($prop === 'mixin') { + $this->flattened = false; + $this->store[] = $pair; + } + else { + // Only store to $this->data if the value does not itself make a + // this() call to avoid circular references. + if (! preg_match(Regex::$patt->thisFunction, $value)) { + $this->data[strtolower($prop)] = $value; + } + $this->add($prop, $value, $index); + } + } + } + } + + public function add($property, $value, $contextIndex = 0) + { + $declaration = new Declaration($property, $value, $contextIndex); + + if ($declaration->valid) { + + $this->index($declaration); + $this->store[] = $declaration; + return $declaration; + } + + return false; + } + + public function reset(array $declaration_stack) + { + $this->store = $declaration_stack; + + $this->updateIndex(); + } + + public function index($declaration) + { + $property = $declaration->property; + + if (isset($this->properties[$property])) { + $this->properties[$property]++; + } + else { + $this->properties[$property] = 1; + } + $this->canonicalProperties[$declaration->canonicalProperty] = true; + } + + public function updateIndex() + { + $this->properties = []; + $this->canonicalProperties = []; + + foreach ($this->store as $declaration) { + $this->index($declaration); + } + } + + public function propertyCount($property) + { + return isset($this->properties[$property]) ? $this->properties[$property] : 0; + } + + public function join($glue = ';') + { + return implode($glue, $this->store); + } + + /* + Aliasing. + */ + public function aliasProperties($vendor_context = null) + { + $aliased_properties =& Crush::$process->aliases['properties']; + + // Bail early if nothing doing. + if (! array_intersect_key($aliased_properties, $this->properties)) { + return; + } + + $stack = []; + $rule_updated = false; + $regex = Regex::$patt; + + foreach ($this->store as $declaration) { + + // Check declaration against vendor context. + if ($vendor_context && $declaration->vendor && $declaration->vendor !== $vendor_context) { + continue; + } + + if ($declaration->skip) { + $stack[] = $declaration; + continue; + } + + // Shim in aliased properties. + if (isset($aliased_properties[$declaration->property])) { + + foreach ($aliased_properties[$declaration->property] as $prop_alias) { + + // If an aliased version already exists do not create one. + if ($this->propertyCount($prop_alias)) { + continue; + } + + // Get property alias vendor. + preg_match($regex->vendorPrefix, $prop_alias, $alias_vendor); + + // Check against vendor context. + if ($vendor_context && $alias_vendor && $alias_vendor[1] !== $vendor_context) { + continue; + } + + // Create the aliased declaration. + $copy = clone $declaration; + $copy->property = $prop_alias; + + // Set the aliased declaration vendor property. + $copy->vendor = null; + if ($alias_vendor) { + $copy->vendor = $alias_vendor[1]; + } + + $stack[] = $copy; + $rule_updated = true; + } + } + + // Un-aliased property or a property alias that has been manually set. + $stack[] = $declaration; + } + + // Re-assign if any updates have been made. + if ($rule_updated) { + $this->reset($stack); + } + } + + public function aliasFunctions($vendor_context = null) + { + $function_aliases =& Crush::$process->aliases['functions']; + $function_alias_groups =& Crush::$process->aliases['function_groups']; + + // The new modified set of declarations. + $new_set = []; + $rule_updated = false; + + // Shim in aliased functions. + foreach ($this->store as $declaration) { + + // No functions, bail. + if (! $declaration->functions || $declaration->skip) { + $new_set[] = $declaration; + continue; + } + + // Get list of functions used in declaration that are alias-able, bail if none. + $intersect = array_intersect_key($declaration->functions, $function_aliases); + if (! $intersect) { + $new_set[] = $declaration; + continue; + } + + // Keep record of which groups have been applied. + $processed_groups = []; + + foreach (array_keys($intersect) as $fn_name) { + + // Store for all the duplicated declarations. + $prefixed_copies = []; + + // Grouped function aliases. + if ($function_aliases[$fn_name][0] === '.') { + + $group_id = $function_aliases[$fn_name]; + + // If this group has been applied we can skip over. + if (isset($processed_groups[$group_id])) { + continue; + } + + // Mark group as applied. + $processed_groups[$group_id] = true; + + $groups =& $function_alias_groups[$group_id]; + + foreach ($groups as $group_key => $replacements) { + + // If the declaration is vendor specific only create aliases for the same vendor. + if ( + ($declaration->vendor && $group_key !== $declaration->vendor) || + ($vendor_context && $group_key !== $vendor_context) + ) { + continue; + } + + $copy = clone $declaration; + + // Make swaps. + $copy->value = preg_replace( + $replacements['find'], + $replacements['replace'], + $copy->value + ); + $prefixed_copies[] = $copy; + $rule_updated = true; + } + + // Post fixes. + if (isset(PostAliasFix::$functions[$group_id])) { + call_user_func(PostAliasFix::$functions[$group_id], $prefixed_copies, $group_id); + } + } + + // Single function aliases. + else { + foreach ($function_aliases[$fn_name] as $fn_alias) { + + // If the declaration is vendor specific only create aliases for the same vendor. + if ($declaration->vendor) { + preg_match(Regex::$patt->vendorPrefix, $fn_alias, $m); + if ( + $m[1] !== $declaration->vendor || + ($vendor_context && $m[1] !== $vendor_context) + ) { + continue; + } + } + + $copy = clone $declaration; + + // Make swaps. + $copy->value = preg_replace( + Regex::make("~{{ LB }}$fn_name(?=\()~iS"), + $fn_alias, + $copy->value + ); + $prefixed_copies[] = $copy; + $rule_updated = true; + } + + // Post fixes. + if (isset(PostAliasFix::$functions[$fn_name])) { + call_user_func(PostAliasFix::$functions[$fn_name], $prefixed_copies, $fn_name); + } + } + + $new_set = array_merge($new_set, $prefixed_copies); + } + $new_set[] = $declaration; + } + + // Re-assign if any updates have been made. + if ($rule_updated) { + $this->reset($new_set); + } + } + + public function aliasDeclarations($vendor_context = null) + { + $declaration_aliases =& Crush::$process->aliases['declarations']; + + // First test for the existence of any aliased properties. + if (! ($intersect = array_intersect_key($declaration_aliases, $this->properties))) { + return; + } + + $intersect = array_flip(array_keys($intersect)); + $new_set = []; + $rule_updated = false; + + foreach ($this->store as $declaration) { + + // Check the current declaration property is actually aliased. + if (isset($intersect[$declaration->property]) && ! $declaration->skip) { + + // Iterate on the current declaration property for value matches. + foreach ($declaration_aliases[$declaration->property] as $value_match => $replacements) { + + // Create new alias declaration if the property and value match. + if ($declaration->value === $value_match) { + + foreach ($replacements as $values) { + + // Check the vendor against context. + if ($vendor_context && $vendor_context !== $values[2]) { + continue; + } + + // If the replacement property is null use the original declaration property. + $new = new Declaration( + ! empty($values[0]) ? $values[0] : $declaration->property, + $values[1] + ); + $new->important = $declaration->important; + $new_set[] = $new; + $rule_updated = true; + } + } + } + } + $new_set[] = $declaration; + } + + // Re-assign if any updates have been made. + if ($rule_updated) { + $this->reset($new_set); + } + } + + public static function parse($str, $options = []) + { + $str = Util::stripCommentTokens($str); + $lines = preg_split('~\s*;\s*~', $str, -1, PREG_SPLIT_NO_EMPTY); + + $options += [ + 'keyed' => false, + 'ignore_directives' => false, + 'lowercase_keys' => false, + 'context' => null, + 'flatten' => false, + 'apply_hooks' => false, + ]; + + $pairs = []; + + foreach ($lines as $line) { + + if (! $options['ignore_directives'] && preg_match(Regex::$patt->ruleDirective, $line, $m)) { + + if (! empty($m[1])) { + $property = 'mixin'; + } + elseif (! empty($m[2])) { + $property = 'extends'; + } + else { + $property = 'name'; + } + $value = trim(substr($line, strlen($m[0]))); + } + elseif (($colon_pos = strpos($line, ':')) !== false) { + + $property = trim(substr($line, 0, $colon_pos)); + $value = trim(substr($line, $colon_pos + 1)); + + if ($options['lowercase_keys']) { + $property = strtolower($property); + } + + if ($options['apply_hooks']) { + Crush::$process->emit('declaration_preprocess', [ + 'property' => &$property, + 'value' => &$value, + ]); + } + } + else { + continue; + } + + if ($property === '' || $value === '') { + continue; + } + + if ($property === 'mixin' && $options['flatten']) { + $pairs = Mixin::merge($pairs, $value, [ + 'keyed' => $options['keyed'], + 'context' => $options['context'], + ]); + } + elseif ($options['keyed']) { + $pairs[$property] = $value; + } + else { + $pairs[] = [$property, $value]; + } + } + + return $pairs; + } + + public function flatten() + { + if ($this->flattened) { + return; + } + + $newSet = []; + foreach ($this->store as $declaration) { + if (is_array($declaration) && $declaration[0] === 'mixin') { + foreach (Mixin::merge([], $declaration[1], ['context' => $this->rule]) as $mixable) { + if ($mixable instanceof Declaration) { + $clone = clone $mixable; + $clone->index = count($newSet); + $newSet[] = $clone; + } + elseif ($mixable[0] === 'extends') { + $this->rule->addExtendSelectors($mixable[1]); + } + else { + $newSet[] = new Declaration($mixable[0], $mixable[1], count($newSet)); + } + } + } + else { + $declaration->index = count($newSet); + $newSet[] = $declaration; + } + } + + $this->reset($newSet); + $this->flattened = true; + } + + public function process() + { + if ($this->processed) { + return; + } + + foreach ($this->store as $index => $declaration) { + + // Execute functions, store as data etc. + $declaration->process($this->rule); + + // Drop declaration if value is now empty. + if (! $declaration->valid) { + unset($this->store[$index]); + } + } + + // data is done with, reclaim memory. + unset($this->data); + + $this->processed = true; + } + + public function expandData($dataset, $property) + { + // Expand shorthand properties to make them available + // as data for this() and query(). + static $expandables = [ + 'margin-top' => 'margin', + 'margin-right' => 'margin', + 'margin-bottom' => 'margin', + 'margin-left' => 'margin', + 'padding-top' => 'padding', + 'padding-right' => 'padding', + 'padding-bottom' => 'padding', + 'padding-left' => 'padding', + 'border-top-width' => 'border-width', + 'border-right-width' => 'border-width', + 'border-bottom-width' => 'border-width', + 'border-left-width' => 'border-width', + 'border-top-left-radius' => 'border-radius', + 'border-top-right-radius' => 'border-radius', + 'border-bottom-right-radius' => 'border-radius', + 'border-bottom-left-radius' => 'border-radius', + 'border-top-color' => 'border-color', + 'border-right-color' => 'border-color', + 'border-bottom-color' => 'border-color', + 'border-left-color' => 'border-color', + ]; + + $dataset =& $this->{$dataset}; + $property_group = isset($expandables[$property]) ? $expandables[$property] : null; + + // Bail if property non-expandable or already set. + if (! $property_group || isset($dataset[$property]) || ! isset($dataset[$property_group])) { + return; + } + + // Get the expandable property value. + $value = $dataset[$property_group]; + + // Top-Right-Bottom-Left "trbl" expandable properties. + $trbl_fmt = null; + switch ($property_group) { + case 'margin': + $trbl_fmt = 'margin-%s'; + break; + case 'padding': + $trbl_fmt = 'padding-%s'; + break; + case 'border-width': + $trbl_fmt = 'border-%s-width'; + break; + case 'border-radius': + $trbl_fmt = 'border-%s-radius'; + break; + case 'border-color': + $trbl_fmt = 'border-%s-color'; + break; + } + if ($trbl_fmt) { + $parts = explode(' ', $value); + $placeholders = []; + + // 4 values. + if (isset($parts[3])) { + $placeholders = $parts; + } + // 3 values. + elseif (isset($parts[2])) { + $placeholders = [$parts[0], $parts[1], $parts[2], $parts[1]]; + } + // 2 values. + elseif (isset($parts[1])) { + $placeholders = [$parts[0], $parts[1], $parts[0], $parts[1]]; + } + // 1 value. + else { + $placeholders = array_pad($placeholders, 4, $parts[0]); + } + + // Set positional variants. + if ($property_group === 'border-radius') { + $positions = [ + 'top-left', + 'top-right', + 'bottom-right', + 'bottom-left', + ]; + } + else { + $positions = [ + 'top', + 'right', + 'bottom', + 'left', + ]; + } + + foreach ($positions as $index => $position) { + $prop = sprintf($trbl_fmt, $position); + $dataset += [$prop => $placeholders[$index]]; + } + } + } +} diff --git a/lib/CssCrush/EventEmitter.php b/lib/CssCrush/EventEmitter.php new file mode 100644 index 0000000..2ab4452 --- /dev/null +++ b/lib/CssCrush/EventEmitter.php @@ -0,0 +1,36 @@ +eventEmitterStorage[$event])) { + $this->eventEmitterStorage[$event] = []; + } + + $id = ++$this->eventEmitterUid; + $this->eventEmitterStorage[$event][$id] = $function; + + return function () use ($event, $id) { + unset($this->eventEmitterStorage[$event][$id]); + }; + } + + public function emit($event, $data = null) + { + if (isset($this->eventEmitterStorage[$event])) { + foreach ($this->eventEmitterStorage[$event] as $function) { + $function($data); + } + } + } +} diff --git a/lib/CssCrush/ExtendArg.php b/lib/CssCrush/ExtendArg.php new file mode 100644 index 0000000..eef511e --- /dev/null +++ b/lib/CssCrush/ExtendArg.php @@ -0,0 +1,37 @@ +name = + $this->raw = $name; + + if (! preg_match(Regex::$patt->rooted_ident, $this->name)) { + + // Not a regular name: Some kind of selector so normalize it for later comparison. + $this->name = + $this->raw = Selector::makeReadable($this->name); + + // If applying the pseudo on output store. + if (substr($this->name, -1) === '!') { + + $this->name = rtrim($this->name, ' !'); + if (preg_match('~\:\:?[\w-]+$~', $this->name, $m)) { + $this->pseudo = $m[0]; + } + } + } + } +} diff --git a/lib/CssCrush/File.php b/lib/CssCrush/File.php new file mode 100644 index 0000000..28403d8 --- /dev/null +++ b/lib/CssCrush/File.php @@ -0,0 +1,45 @@ +process = $process; + $io = $process->io; + + Crush::runStat('paths'); + + if ($process->options->cache) { + $process->cacheData = $io->getCacheData(); + if ($io->validateCache()) { + $this->url = $io->getOutputUrl(); + $this->path = $io->getOutputDir() . '/' . $io->getOutputFilename(); + $process->release(); + + return; + } + } + + $string = $process->compile(); + + if ($io->write($string)) { + $this->url = $io->getOutputUrl(); + $this->path = $io->getOutputDir() . '/' . $io->getOutputFilename(); + } + } + + public function __toString() + { + return $this->url; + } +} diff --git a/lib/CssCrush/Fragment.php b/lib/CssCrush/Fragment.php new file mode 100644 index 0000000..334a5ba --- /dev/null +++ b/lib/CssCrush/Fragment.php @@ -0,0 +1,46 @@ +name = $options['name']; + } + + public function __invoke(?array $args = null, $str = null) + { + $str = parent::__invoke($args); + + // Flatten all fragment calls within the template string. + while (preg_match(Regex::$patt->fragmentInvoke, $str, $m, PREG_OFFSET_CAPTURE)) { + + $name = strtolower($m['name'][0]); + $fragment = isset(Crush::$process->fragments[$name]) ? Crush::$process->fragments[$name] : null; + + $replacement = ''; + $start = $m[0][1]; + $length = strlen($m[0][0]); + + // Skip over same named fragments to avoid infinite recursion. + if ($fragment && $name !== $this->name) { + $args = []; + if (isset($m['parens'][1])) { + $args = Functions::parseArgs($m['parens_content'][0]); + } + $replacement = $fragment($args); + } + $str = substr_replace($str, $replacement, $start, $length); + } + + return $str; + } +} diff --git a/lib/CssCrush/Functions.php b/lib/CssCrush/Functions.php new file mode 100644 index 0000000..51909f9 --- /dev/null +++ b/lib/CssCrush/Functions.php @@ -0,0 +1,326 @@ + 'CssCrush\fn__query', + + // These functions can be any order. + 'math' => 'CssCrush\fn__math', + 'hsla-adjust' => 'CssCrush\fn__hsla_adjust', + 'hsl-adjust' => 'CssCrush\fn__hsl_adjust', + 'h-adjust' => 'CssCrush\fn__h_adjust', + 's-adjust' => 'CssCrush\fn__s_adjust', + 'l-adjust' => 'CssCrush\fn__l_adjust', + 'a-adjust' => 'CssCrush\fn__a_adjust', + ]; + + public $register = []; + + protected $pattern; + + protected $patternOptions; + + public function __construct($register = []) + { + $this->register = $register; + } + + public function add($name, $callback) + { + $this->register[$name] = $callback; + } + + public function remove($name) + { + unset($this->register[$name]); + } + + public function setPattern($useAll = false) + { + if ($useAll) { + $this->register = self::$builtins + $this->register; + } + + $this->pattern = Functions::makePattern(array_keys($this->register)); + } + + public function apply($str, ?\stdClass $context = null) + { + if (strpos($str, '(') === false) { + return $str; + } + + if (! $this->pattern) { + $this->setPattern(); + } + + if (! preg_match($this->pattern, $str)) { + return $str; + } + + $matches = Regex::matchAll($this->pattern, $str); + + while ($match = array_pop($matches)) { + + if (isset($match['function']) && $match['function'][1] !== -1) { + list($function, $offset) = $match['function']; + } + else { + list($function, $offset) = $match['simple_function']; + } + + if (! preg_match(Regex::$patt->parens, $str, $parens, PREG_OFFSET_CAPTURE, $offset)) { + continue; + } + + $openingParen = $parens[0][1]; + $closingParen = $openingParen + strlen($parens[0][0]); + $rawArgs = trim($parens['parens_content'][0]); + + // Update the context function identifier. + if ($context) { + $context->function = $function; + } + + $returns = ''; + if (isset($this->register[$function])) { + $fn = $this->register[$function]; + if (is_array($fn) && !empty($fn['parse_args'])) { + $returns = $fn['callback'](self::parseArgs($rawArgs), $context); + } + else { + $returns = $fn($rawArgs, $context); + } + } + + if (! is_null($returns)) { + $str = substr_replace($str, $returns, $offset, $closingParen - $offset); + } + } + + return $str; + } + + + ############################# + # API and helpers. + + public static function parseArgs($input, $allowSpaceDelim = false) + { + $options = []; + if ($allowSpaceDelim) { + $options['regex'] = Regex::$patt->argListSplit; + } + + return Util::splitDelimList($input, $options); + } + + /* + Quick argument list parsing for functions that take 1 or 2 arguments + with the proviso the first argument is an ident. + */ + public static function parseArgsSimple($input) + { + return preg_split(Regex::$patt->argListSplit, $input, 2); + } + + public static function makePattern($functionNames) + { + $idents = []; + $nonIdents = []; + + foreach ($functionNames as $functionName) { + if (preg_match(Regex::$patt->ident, $functionName[0])) { + $idents[] = preg_quote($functionName); + } + else { + $nonIdents[] = preg_quote($functionName); + } + } + + if ($idents) { + $idents = '{{ LB }}-?(?' . implode('|', $idents) . ')'; + } + if ($nonIdents) { + $nonIdents = '(?' . implode('|', $nonIdents) . ')'; + } + + if ($idents && $nonIdents) { + $patt = "(?:$idents|$nonIdents)"; + } + elseif ($idents) { + $patt = $idents; + } + elseif ($nonIdents) { + $patt = $nonIdents; + } + + return Regex::make("~$patt\(~iS"); // @phpstan-ignore-line variable.undefined + } +} + + +############################# +# Stock CSS functions. + +function fn__math($input) { + + list($expression, $unit) = array_pad(Functions::parseArgs($input), 2, ''); + + // Swap in math constants. + $expression = preg_replace( + ['~\bpi\b~i'], + [M_PI], + $expression); + + // If no unit is specified scan expression. + if (! $unit) { + $numPatt = Regex::$classes->number; + if (preg_match("~\b{$numPatt}(?[A-Za-z]{2,4}\b|%)~", $expression, $m)) { + $unit = $m['unit']; + } + } + + // Filter expression so it's just characters necessary for simple math. + $expression = preg_replace("~[^.0-9/*()+-]~S", '', $expression); + + $evalExpression = "return $expression;"; + $result = false; + + if (class_exists('\\ParseError')) { + try { + $result = @eval($evalExpression); + } + catch (\Error $e) {} + } + else { + $result = @eval($evalExpression); + } + + return ($result === false ? 0 : round($result, 5)) . $unit; +} + +function fn__hsla_adjust($input) { + list($color, $h, $s, $l, $a) = array_pad(Functions::parseArgs($input, true), 5, 0); + return Color::test($color) ? Color::colorAdjust($color, [$h, $s, $l, $a]) : ''; +} + +function fn__hsl_adjust($input) { + list($color, $h, $s, $l) = array_pad(Functions::parseArgs($input, true), 4, 0); + return Color::test($color) ? Color::colorAdjust($color, [$h, $s, $l, 0]) : ''; +} + +function fn__h_adjust($input) { + list($color, $h) = array_pad(Functions::parseArgs($input, true), 2, 0); + return Color::test($color) ? Color::colorAdjust($color, [$h, 0, 0, 0]) : ''; +} + +function fn__s_adjust($input) { + list($color, $s) = array_pad(Functions::parseArgs($input, true), 2, 0); + return Color::test($color) ? Color::colorAdjust($color, [0, $s, 0, 0]) : ''; +} + +function fn__l_adjust($input) { + list($color, $l) = array_pad(Functions::parseArgs($input, true), 2, 0); + return Color::test($color) ? Color::colorAdjust($color, [0, 0, $l, 0]) : ''; +} + +function fn__a_adjust($input) { + list($color, $a) = array_pad(Functions::parseArgs($input, true), 2, 0); + return Color::test($color) ? Color::colorAdjust($color, [0, 0, 0, $a]) : ''; +} + +function fn__this($input, $context) { + + $args = Functions::parseArgsSimple($input); + $property = $args[0]; + + // Function relies on a context rule, bail if none. + if (! isset($context->rule)) { + return ''; + } + $rule = $context->rule; + + $rule->declarations->expandData('data', $property); + + if (isset($rule->declarations->data[$property])) { + + return $rule->declarations->data[$property]; + } + + // Fallback value. + elseif (isset($args[1])) { + + return $args[1]; + } + + return ''; +} + +function fn__query($input, $context) { + + $args = Functions::parseArgs($input); + + // Context property is required. + if (! count($args) || ! isset($context->property)) { + return ''; + } + + list($target, $property, $fallback) = $args + [null, $context->property, null]; + + if (strtolower($property) === 'default') { + $property = $context->property; + } + + if (! preg_match(Regex::$patt->rooted_ident, $target)) { + $target = Selector::makeReadable($target); + } + + $targetRule = null; + $references =& Crush::$process->references; + + switch (strtolower($target)) { + case 'parent': + $targetRule = $context->rule->parent; + break; + case 'previous': + $targetRule = $context->rule->previous; + break; + case 'next': + $targetRule = $context->rule->next; + break; + case 'top': + $targetRule = $context->rule->parent; + while ($targetRule && $targetRule->parent && $targetRule = $targetRule->parent); + break; + default: + if (isset($references[$target])) { + $targetRule = $references[$target]; + } + break; + } + + $result = ''; + if ($targetRule) { + $targetRule->declarations->process(); + $targetRule->declarations->expandData('queryData', $property); + if (isset($targetRule->declarations->queryData[$property])) { + $result = $targetRule->declarations->queryData[$property]; + } + } + + if ($result === '' && isset($fallback)) { + $result = $fallback; + } + + return $result; +} diff --git a/lib/CssCrush/IO.php b/lib/CssCrush/IO.php new file mode 100644 index 0000000..9af935b --- /dev/null +++ b/lib/CssCrush/IO.php @@ -0,0 +1,231 @@ +process = $process; + } + + public function init() + { + $this->process->cacheFile = "{$this->process->output->dir}/.csscrush"; + } + + public function getOutputDir() + { + $outputDir = $this->process->options->output_dir; + + return $outputDir ? $outputDir : $this->process->input->dir; + } + + public function getOutputFilename() + { + $options = $this->process->options; + + $inputBasename = $this->process->input->filename + ? basename($this->process->input->filename, '.css') + : 'styles'; + + $outputBasename = $inputBasename; + + if (! empty($options->output_file)) { + $outputBasename = basename($options->output_file, '.css'); + } + + if ($this->process->input->dir === $this->getOutputDir() && $inputBasename === $outputBasename) { + $outputBasename .= '.crush'; + } + + return "$outputBasename.css"; + } + + public function getOutputUrl() + { + $process = $this->process; + $options = $process->options; + $filename = $process->output->filename; + + $url = $process->output->dirUrl . '/' . $filename; + + // Make URL relative if the input path was relative. + $input_path = new Url($process->input->raw); + if ($input_path->isRelative) { + $url = Util::getLinkBetweenPaths(Crush::$config->scriptDir, $process->output->dir) . $filename; + } + + // Optional query-string timestamp. + if ($options->versioning !== false) { + $url .= '?'; + if (isset($process->cacheData[$filename]['datem_sum'])) { + $url .= $process->cacheData[$filename]['datem_sum']; + } + else { + $url .= time(); + } + } + + return $url; + } + + public function validateCache() + { + $process = $this->process; + $options = $process->options; + $input = $process->input; + + $dir = $this->getOutputDir(); + $filename = $this->getOutputFilename(); + $path = "$dir/$filename"; + + if (! file_exists($path)) { + debug("File '$path' not cached."); + + return false; + } + + if (! isset($process->cacheData[$filename])) { + debug('Cached file exists but is not registered.'); + + return false; + } + + $data =& $process->cacheData[$filename]; + + // Make stack of file mtimes starting with the input file. + $file_sums = [$input->mtime]; + foreach ($data['imports'] as $import_file) { + + // Check if this is docroot relative or input dir relative. + $root = strpos($import_file, '/') === 0 ? $process->docRoot : $input->dir; + $import_filepath = realpath($root) . "/$import_file"; + + if (file_exists($import_filepath)) { + $file_sums[] = filemtime($import_filepath); + } + else { + // File has been moved, remove old file and skip to compile. + debug('Recompiling - an import file has been moved.'); + + return false; + } + } + + $files_changed = $data['datem_sum'] != array_sum($file_sums); + if ($files_changed) { + debug('Files have been modified. Recompiling.'); + } + + // Compare runtime options and cached options for differences. + // Cast because the cached options may be a \stdClass if an IO adapter has been used. + $options_changed = false; + $cached_options = (array) $data['options']; + $active_options = $options->get(); + foreach ($cached_options as $key => &$value) { + if (isset($active_options[$key]) && $active_options[$key] !== $value) { + debug('Options have been changed. Recompiling.'); + $options_changed = true; + break; + } + } + + if (! $options_changed && ! $files_changed) { + debug("Files and options have not been modified, returning cached file."); + + return true; + } + else { + $data['datem_sum'] = array_sum($file_sums); + + return false; + } + } + + public function getCacheData() + { + $process = $this->process; + + if (file_exists($process->cacheFile) && $process->cacheData) { + + // Already loaded and config file exists in the current directory + return; + } + + $cache_data_exists = file_exists($process->cacheFile); + $cache_data_file_is_writable = $cache_data_exists ? is_writable($process->cacheFile) : false; + $cache_data = []; + + if ( + $cache_data_exists && + $cache_data_file_is_writable && + $cache_data = json_decode(file_get_contents($process->cacheFile), true) + ) { + // Successfully loaded config file. + debug('Cache data loaded.'); + } + else { + // Config file may exist but not be writable (may not be visible in some ftp situations?) + if ($cache_data_exists) { + if (! @unlink($process->cacheFile)) { + notice('Could not delete cache data file.'); + } + } + else { + debug('Creating cache data file.'); + } + Util::filePutContents($process->cacheFile, json_encode([])); + } + + return $cache_data; + } + + public function saveCacheData() + { + $process = $this->process; + + debug('Saving config.'); + + Util::filePutContents($process->cacheFile, json_encode($process->cacheData, JSON_PRETTY_PRINT)); + } + + public function write(StringObject $string) + { + $process = $this->process; + + $dir = $this->getOutputDir(); + $filename = $this->getOutputFilename(); + $sourcemapFilename = "$filename.map"; + + if ($process->sourceMap) { + $string->append($process->newline . "/*# sourceMappingURL=$sourcemapFilename */"); + } + + if (Util::filePutContents("$dir/$filename", $string)) { + + if ($process->sourceMap) { + Util::filePutContents("$dir/$sourcemapFilename", + json_encode($process->sourceMap, JSON_PRETTY_PRINT)); + } + + if ($process->options->stat_dump) { + $statFile = is_string($process->options->stat_dump) ? + $process->options->stat_dump : "$dir/$filename.json"; + + $GLOBALS['CSSCRUSH_STAT_FILE'] = $statFile; + Util::filePutContents($statFile, json_encode(csscrush_stat(), JSON_PRETTY_PRINT)); + } + + return true; + } + + return false; + } +} diff --git a/lib/CssCrush/IO/Watch.php b/lib/CssCrush/IO/Watch.php new file mode 100644 index 0000000..1604ecd --- /dev/null +++ b/lib/CssCrush/IO/Watch.php @@ -0,0 +1,48 @@ +process; + $options = $process->options; + + $input_basename = $output_basename = basename($process->input->filename, '.css'); + + if (! empty($options->output_file)) { + $output_basename = basename($options->output_file, '.css'); + } + + $suffix = '.crush'; + if (($process->input->dir !== $process->output->dir) || ($input_basename !== $output_basename)) { + $suffix = ''; + } + + return "$output_basename$suffix.css"; + } + + public function getCacheData() + { + // Clear results from earlier processes. + clearstatcache(); + $this->process->cacheData = []; + + return self::$cacheData; + } + + public function saveCacheData() + { + self::$cacheData = $this->process->cacheData; + } +} diff --git a/lib/CssCrush/Importer.php b/lib/CssCrush/Importer.php new file mode 100644 index 0000000..48653be --- /dev/null +++ b/lib/CssCrush/Importer.php @@ -0,0 +1,378 @@ +process = $process; + } + + public function collate() + { + $process = $this->process; + $options = $process->options; + $regex = Regex::$patt; + $input = $process->input; + + $str = ''; + + // Keep track of all import file info for cache data. + $mtimes = []; + $filenames = []; + + // Resolve main input; a string of css or a file. + if (isset($input->string)) { + $str .= $input->string; + $process->sources[] = 'Inline CSS'; + } + else { + $str .= file_get_contents($input->path); + $process->sources[] = $input->path; + } + + // If there's a parsing error go no further. + if (! $this->prepareImport($str)) { + + return $str; + } + + // This may be set non-zero during the script if an absolute @import URL is encountered. + $search_offset = 0; + + // Recurses until the nesting heirarchy is flattened and all import files are inlined. + while (preg_match($regex->import, $str, $match, PREG_OFFSET_CAPTURE, $search_offset)) { + + $match_len = strlen($match[0][0]); + $match_start = $match[0][1]; + + $import = new \stdClass(); + $import->url = $process->tokens->get($match[1][0]); + $import->media = trim($match[2][0]); + + // Protocoled import urls are not processed. Stash for prepending to output. + if ($import->url->protocol) { + $str = substr_replace($str, '', $match_start, $match_len); + $process->absoluteImports[] = $import; + continue; + } + + // Resolve import path information. + $import->path = null; + if ($import->url->isRooted) { + $import->path = realpath($process->docRoot . $import->url->value); + } + else { + $url =& $import->url; + $candidates = ["$input->dir/$url->value"]; + + // If `import_path` option is set implicit relative urls + // are additionally searched under specified import path(s). + if (is_array($options->import_path) && $url->isRelativeImplicit()) { + foreach ($options->import_path as $importPath) { + $candidates[] = "$importPath/$url->originalValue"; + } + } + foreach ($candidates as $candidate) { + if (file_exists($candidate)) { + $import->path = realpath($candidate); + break; + } + } + } + + // If unsuccessful getting import contents continue with the import line removed. + $import->content = $import->path ? @file_get_contents($import->path) : false; + if ($import->content === false) { + $errDesc = 'was not found'; + if ($import->path && ! is_readable($import->path)) { + $errDesc = 'is not readable'; + } + if (! empty($process->sources)) { + $errDesc .= " (from within {$process->input->dir})"; + } + notice("@import 'http://github.com/%7B$import-%3Eurl-%3Evalue%7D' $errDesc"); + $str = substr_replace($str, '', $match_start, $match_len); + continue; + } + + $import->dir = dirname($import->path); + $import->relativeDir = Util::getLinkBetweenPaths($input->dir, $import->dir); + + // Import file exists so register it. + $process->sources[] = $import->path; + $mtimes[] = filemtime($import->path); + $filenames[] = $import->relativeDir . basename($import->path); + + // If the import content doesn't pass syntax validation skip to next import. + if (! $this->prepareImport($import->content)) { + + $str = substr_replace($str, '', $match_start, $match_len); + continue; + } + + // Alter all embedded import URLs to be relative to the host-file. + foreach (Regex::matchAll($regex->import, $import->content) as $m) { + + $nested_url = $process->tokens->get($m[1][0]); + + // Resolve rooted paths. + if ($nested_url->isRooted) { + $link = Util::getLinkBetweenPaths(dirname($nested_url->getAbsolutePath()), $import->dir); + $nested_url->update($link . basename($nested_url->value)); + } + elseif (strlen($import->relativeDir)) { + $nested_url->prepend("$import->relativeDir/"); + } + } + + // Optionally rewrite relative url and custom function data-uri references. + if ($options->rewrite_import_urls) { + $this->rewriteImportedUrls($import); + } + + if ($import->media) { + $import->content = "@media $import->media {{$import->content}}"; + } + + $str = substr_replace($str, $import->content, $match_start, $match_len); + } + + // Save only if caching is on and the hostfile object is associated with a real file. + if ($input->path && $options->cache) { + + $process->cacheData[$process->output->filename] = [ + 'imports' => $filenames, + 'datem_sum' => array_sum($mtimes) + $input->mtime, + 'options' => $options->get(), + ]; + $process->io->saveCacheData(); + } + + return $str; + } + + protected function rewriteImportedUrls($import) + { + $link = Util::getLinkBetweenPaths($this->process->input->dir, dirname($import->path)); + + if (empty($link)) { + return; + } + + // Match all urls that are not imports. + preg_match_all(Regex::make('~(?content, $matches); + + foreach ($matches[0] as $token) { + + $url = $this->process->tokens->get($token); + + if ($url->isRelative) { + $url->prepend($link); + } + } + } + + protected function prepareImport(&$str) + { + $regex = Regex::$patt; + $process = $this->process; + $tokens = $process->tokens; + + // Convert all EOL to unix style. + $str = preg_replace('~\r\n?~', "\n", $str); + + // Trimming to reduce regex backtracking. + $str = rtrim($this->captureCommentAndString(rtrim($str))); + + if (! $this->syntaxCheck($str)) { + + $str = ''; + return false; + } + + // Normalize double-colon pseudo elements for backwards compatability. + $str = preg_replace('~::(after|before|first-(?:letter|line))~iS', ':$1', $str); + + // Store @charset if set. + if (preg_match($regex->charset, $str, $m)) { + $replace = ''; + if (! $process->charset) { + // Keep track of newlines for line numbering. + $replace = str_repeat("\n", substr_count($m[0], "\n")); + $process->charset = trim($tokens->get($m[1]), '"\''); + } + $str = preg_replace($regex->charset, $replace, $str); + } + + $str = $tokens->captureUrls($str, true); + + $this->addMarkers($str); + + $str = Util::normalizeWhiteSpace($str); + + return true; + } + + protected function syntaxCheck(&$str) + { + // Catch obvious typing errors. + $errors = false; + $current_file = 'file://' . end($this->process->sources); + $balanced_parens = substr_count($str, "(") === substr_count($str, ")"); + $balanced_curlies = substr_count($str, "{") === substr_count($str, "}"); + + $validate_pairings = function ($str, $pairing) use ($current_file) + { + if ($pairing === '{}') { + $opener_patt = '~\{~'; + $balancer_patt = Regex::make('~^{{block}}~'); + } + else { + $opener_patt = '~\(~'; + $balancer_patt = Regex::make('~^{{parens}}~'); + } + + // Find unbalanced opening brackets. + preg_match_all($opener_patt, $str, $matches, PREG_OFFSET_CAPTURE); + foreach ($matches[0] as $m) { + $offset = $m[1]; + if (! preg_match($balancer_patt, substr($str, $offset), $m)) { + $substr = substr($str, 0, $offset); + $line = substr_count($substr, "\n") + 1; + $column = strlen($substr) - strrpos($substr, "\n"); + return "Unbalanced '{$pairing[0]}' in $current_file, Line $line, Column $column."; + } + } + + // Reverse the string (and brackets) to find stray closing brackets. + $str = strtr(strrev($str), $pairing, strrev($pairing)); + + preg_match_all($opener_patt, $str, $matches, PREG_OFFSET_CAPTURE); + foreach ($matches[0] as $m) { + $offset = $m[1]; + $substr = substr($str, $offset); + if (! preg_match($balancer_patt, $substr, $m)) { + $line = substr_count($substr, "\n") + 1; + $column = strpos($substr, "\n"); + return "Stray '{$pairing[1]}' in $current_file, Line $line, Column $column."; + } + } + + return false; + }; + + if (! $balanced_curlies) { + $errors = true; + warning($validate_pairings($str, '{}') ?: "Unbalanced '{' in $current_file."); + } + if (! $balanced_parens) { + $errors = true; + warning($validate_pairings($str, '()') ?: "Unbalanced '(' in $current_file."); + } + + return $errors ? false : true; + } + + protected function addMarkers(&$str) + { + $process = $this->process; + $currentFileIndex = count($process->sources) - 1; + + static $patt; + if (! $patt) { + $patt = Regex::make('~ + (?:^|(?<=[;{}])) + (? + (?: \s | {{c_token}} )* + ) + (? + (?: + # Some @-rules are treated like standard rule blocks. + @(?: (?i)page|abstract|font-face(?-i) ) {{RB}} [^{]* + | + [^@;{}]+ + ) + ) + \{ + ~xS'); + } + + $count = preg_match_all($patt, $str, $matches, PREG_OFFSET_CAPTURE); + while ($count--) { + + $selectorOffset = $matches['selector'][$count][1]; + + $line = 0; + $before = substr($str, 0, $selectorOffset); + if ($selectorOffset) { + $line = substr_count($before, "\n"); + } + + $pointData = [$currentFileIndex, $line]; + + // Source maps require column index too. + if ($process->generateMap) { + $pointData[] = strlen($before) - (strrpos($before, "\n") ?: 0); + } + + // Splice in marker token (packing point_data into string is more memory efficient). + $str = substr_replace( + $str, + $process->tokens->add(implode(',', $pointData), 't'), + $selectorOffset, + 0); + } + } + + protected function captureCommentAndString($str) + { + $process = $this->process; + $callback = function ($m) use ($process) { + + $fullMatch = $m[0]; + + if (strpos($fullMatch, '/*') === 0) { + + // Bail without storing comment if output is minified or a private comment. + if ($process->minifyOutput || strpos($fullMatch, '/*$') === 0) { + + $label = ''; + } + else { + // Fix broken comments as they will break any subsquent + // imported files that are inlined. + if (! preg_match('~\*/$~', $fullMatch)) { + $fullMatch .= '*/'; + } + $label = $process->tokens->add($fullMatch, 'c'); + } + } + else { + // Fix broken strings as they will break any subsquent + // imported files that are inlined. + if ($fullMatch[0] !== $fullMatch[strlen($fullMatch)-1]) { + $fullMatch .= $fullMatch[0]; + } + + // Backticked literals may have been used for custom property values. + if ($fullMatch[0] === '`') { + $fullMatch = preg_replace('~\x5c`~', '`', trim($fullMatch, '`')); + } + + $label = $process->tokens->add($fullMatch, 's'); + } + + return $process->generateMap ? Tokens::pad($label, $fullMatch) : $label; + }; + + return preg_replace_callback(Regex::$patt->commentAndString, $callback, $str); + } +} diff --git a/lib/CssCrush/Iterator.php b/lib/CssCrush/Iterator.php new file mode 100644 index 0000000..38940ac --- /dev/null +++ b/lib/CssCrush/Iterator.php @@ -0,0 +1,70 @@ +store = $items; + } + + /* + IteratorAggregate implementation. + */ + public function getIterator(): \Traversable + { + return new \ArrayIterator($this->store); + } + + /* + ArrayAccess implementation. + */ + public function offsetExists($index): bool + { + return array_key_exists($index, $this->store); + } + + public function offsetGet($index): mixed + { + return isset($this->store[$index]) ? $this->store[$index] : null; + } + + public function offsetSet($index, $value): void + { + $this->store[$index] = $value; + } + + public function offsetUnset($index): void + { + unset($this->store[$index]); + } + + public function getContents() + { + return $this->store; + } + + /* + Countable implementation. + */ + public function count(): int + { + return count($this->store); + } + + /* + Collection interface. + */ + public function filter($filterer, $op = '===') + { + $collection = new Collection($this->store); + return $collection->filter($filterer, $op); + } +} diff --git a/lib/CssCrush/Logger.php b/lib/CssCrush/Logger.php new file mode 100644 index 0000000..28b52d2 --- /dev/null +++ b/lib/CssCrush/Logger.php @@ -0,0 +1,150 @@ +error($message, $context); + } + + /** + * Action must be taken immediately. + * + * Example: Entire website down, database unavailable, etc. This should + * trigger the SMS alerts and wake you up. + * + * @param string $message + * @param array $context + * @return null + */ + public function alert($message, array $context = []) + { + $this->error($message, $context); + } + + /** + * Critical conditions. + * + * Example: Application component unavailable, unexpected exception. + * + * @param string $message + * @param array $context + * @return null + */ + public function critical($message, array $context = []) + { + $this->error($message, $context); + } + + /** + * Runtime errors that do not require immediate action but should typically + * be logged and monitored. + * + * @param string $message + * @param array $context + * @return null + */ + public function error($message, array $context = []) + { + trigger_error($message, E_USER_ERROR); + } + + /** + * Exceptional occurrences that are not errors. + * + * Example: Use of deprecated APIs, poor use of an API, undesirable things + * that are not necessarily wrong. + * + * @param string $message + * @param array $context + * @return null + */ + public function warning($message, array $context = []) + { + trigger_error($message, E_USER_WARNING); + } + + /** + * Normal but significant events. + * + * @param string $message + * @param array $context + * @return null + */ + public function notice($message, array $context = []) + { + trigger_error($message, E_USER_NOTICE); + } + + /** + * Interesting events. + * + * Example: User logs in, SQL logs. + * + * @param string $message + * @param array $context + * @return null + */ + public function info($message, array $context = []) + { + $this->debug($message, $context); + } + + /** + * Detailed debug information. + * + * @param string $message + * @param array $context + * @return null + */ + public function debug($message, array $context = []) + { + if (! empty($context['label'])) { + $label = $context['label']; + $label = PHP_EOL . "$label" . PHP_EOL . str_repeat('=', strlen($label)) . PHP_EOL; + } + else { + $label = ''; + } + + if (is_string($message)) { + Crush::$process->debugLog[] = "$label$message"; + } + else { + ob_start(); + ! empty($context['var_dump']) ? var_dump($message) : print_r($message); + Crush::$process->debugLog[] = $label . ob_get_clean(); + } + } + + /** + * Logs with an arbitrary level. + * + * @param mixed $level + * @param string $message + * @param array $context + * @return null + */ + public function log($level, $message, array $context = []) + { + $log_levels = array_flip(get_class_methods(__CLASS__)); + unset($log_levels['log']); + + if (isset($log_levels[$level])) { + return $this->$level($message, $context); + } + } +} diff --git a/lib/CssCrush/Mixin.php b/lib/CssCrush/Mixin.php new file mode 100644 index 0000000..b4df1a4 --- /dev/null +++ b/lib/CssCrush/Mixin.php @@ -0,0 +1,106 @@ +template = new Template($block); + } + + public static function call($message, $context = null) + { + $process = Crush::$process; + $mixable = null; + $message = trim($message); + + // Test for mixin or abstract rule. e.g: + // named-mixin( 50px, rgba(0,0,0,0), left 100% ) + // abstract-rule + if (preg_match(Regex::make('~^(?{{ident}}) {{parens}}?~xS'), $message, $message_match)) { + + $name = $message_match['name']; + + if (isset($process->mixins[$name])) { + + $mixable = $process->mixins[$name]; + } + elseif (isset($process->references[$name])) { + + $mixable = $process->references[$name]; + } + } + + // If no mixin or abstract rule matched, look for matching selector + if (! $mixable) { + + $selector_test = Selector::makeReadable($message); + + if (isset($process->references[$selector_test])) { + $mixable = $process->references[$selector_test]; + } + } + + // Avoid infinite recursion. + if (! $mixable || $mixable === $context) { + + return false; + } + elseif ($mixable instanceof Mixin) { + + $args = []; + $raw_args = isset($message_match['parens_content']) ? trim($message_match['parens_content']) : null; + if ($raw_args) { + $args = Util::splitDelimList($raw_args); + } + + return DeclarationList::parse($mixable->template->__invoke($args), [ + 'flatten' => true, + 'context' => $mixable, + ]); + } + elseif ($mixable instanceof Rule) { + + return $mixable->declarations->store; + } + } + + public static function merge(array $input, $message_list, $options = []) + { + $context = isset($options['context']) ? $options['context'] : null; + + $mixables = []; + foreach (Util::splitDelimList($message_list) as $message) { + if ($result = self::call($message, $context)) { + $mixables = array_merge($mixables, $result); + } + } + + while ($mixable = array_shift($mixables)) { + if ($mixable instanceof Declaration) { + $input[] = $mixable; + } + else { + list($property, $value) = $mixable; + if ($property === 'mixin') { + $input = Mixin::merge($input, $value, $options); + } + elseif (! empty($options['keyed'])) { + $input[$property] = $value; + } + else { + $input[] = [$property, $value]; + } + } + } + + return $input; + } +} diff --git a/lib/CssCrush/Options.php b/lib/CssCrush/Options.php new file mode 100644 index 0000000..54b87ce --- /dev/null +++ b/lib/CssCrush/Options.php @@ -0,0 +1,176 @@ + true, + 'formatter' => null, + 'versioning' => true, + 'boilerplate' => true, + 'vars' => [], + 'cache' => true, + 'context' => null, + 'import_path' => null, + 'output_file' => null, + 'output_dir' => null, + 'asset_dir' => null, + 'doc_root' => null, + 'vendor_target' => 'all', + 'rewrite_import_urls' => true, + 'plugins' => null, + 'settings' => [], + 'stat_dump' => false, + 'source_map' => false, + 'newlines' => 'use-platform', + ]; + + public function __construct(array $options = [], ?Options $defaults = null) + { + $options = array_change_key_case($options, CASE_LOWER); + + if ($defaults) { + $options += $defaults->get(); + } + + if (! empty($options['enable'])) { + if (empty($options['plugins'])) { + $options['plugins'] = $options['enable']; + } + unset($options['enable']); + } + + foreach ($options + self::$standardOptions as $name => $value) { + $this->__set($name, $value); + } + } + + public function __set($name, $value) + { + $this->inputOptions[$name] = $value; + + switch ($name) { + + case 'formatter': + if (is_string($value) && isset(Crush::$config->formatters[$value])) { + $value = Crush::$config->formatters[$value]; + } + if (! is_callable($value)) { + $value = null; + } + break; + + // Path options. + case 'boilerplate': + if (is_string($value)) { + $value = Util::resolveUserPath($value); + } + break; + + case 'stat_dump': + if (is_string($value)) { + $value = Util::resolveUserPath($value, function ($path) { + touch($path); + return $path; + }); + } + break; + + case 'output_dir': + case 'asset_dir': + if (is_string($value)) { + $value = Util::resolveUserPath($value, function ($path) use ($name) { + if (! @mkdir($path, 0755, true)) { + warning("Could not create directory $path (setting `$name` option)."); + } + else { + debug("Created directory $path (setting `$name` option)."); + } + return $path; + }); + } + break; + + // Path options that only accept system paths. + case 'context': + case 'doc_root': + if (is_string($value)) { + $value = Util::normalizePath(realpath($value)); + } + break; + + case 'import_path': + if ($value) { + if (is_string($value)) { + $value = preg_split('~\s*,\s*~', trim($value)); + } + $value = array_filter(array_map(function ($path) { + return Util::normalizePath(realpath($path)); + }, $value)); + } + break; + + // Options used internally as arrays. + case 'plugins': + $value = (array) $value; + break; + } + + $this->computedOptions[$name] = $value; + } + + public function __get($name) + { + switch ($name) { + case 'newlines': + switch ($this->inputOptions[$name]) { + case 'windows': + case 'win': + return "\r\n"; + case 'unix': + return "\n"; + case 'use-platform': + default: + return PHP_EOL; + } + break; + + case 'minify': + if (isset($this->computedOptions['formatter'])) { + return false; + } + break; + + case 'formatter': + if (empty($this->inputOptions['minify'])) { + return isset($this->computedOptions['formatter']) ? + $this->computedOptions['formatter'] : 'CssCrush\fmtr_block'; + } + } + + return isset($this->computedOptions[$name]) ? $this->computedOptions[$name] : null; + } + + public function __isset($name) + { + return isset($this->inputOptions[$name]); + } + + public function get($computed = false) + { + return $computed ? $this->computedOptions : self::filter($this->inputOptions); + } + + public static function filter(?array $optionsArray = null) + { + return $optionsArray ? array_intersect_key($optionsArray, self::$standardOptions) : self::$standardOptions; + } +} diff --git a/lib/CssCrush/PostAliasFix.php b/lib/CssCrush/PostAliasFix.php new file mode 100644 index 0000000..2b7b61e --- /dev/null +++ b/lib/CssCrush/PostAliasFix.php @@ -0,0 +1,26 @@ +cacheData = []; + $this->mixins = []; + $this->fragments = []; + $this->references = []; + $this->absoluteImports = []; + $this->charset = null; + $this->sources = []; + $this->vars = []; + $this->plugins = []; + $this->misc = new \stdClass(); + $this->input = new \stdClass(); + $this->output = new \stdClass(); + $this->tokens = new Tokens(); + $this->functions = new Functions(); + $this->sourceMap = null; + $this->selectorAliases = []; + $this->selectorAliasesPatt = null; + $this->io = new Crush::$config->io($this); + + $this->errors = []; + $this->warnings = []; + $this->debugLog = []; + $this->stat = []; + + // Copy config values. + $this->aliases = $config->aliases; + + // Options. + $this->options = new Options($user_options, $config->options); + + // Context options. + $context += ['type' => 'filter', 'data' => '']; + $this->ioContext = $context['type']; + + // Keep track of global vars to maintain cache integrity. + $this->options->global_vars = $config->vars; + + // Shortcut commonly used options to avoid __get() overhead. + $this->docRoot = isset($this->options->doc_root) ? $this->options->doc_root : $config->docRoot; + $this->generateMap = $this->ioContext === 'file' && $this->options->__get('source_map'); + $this->ruleFormatter = $this->options->__get('formatter'); + $this->minifyOutput = $this->options->__get('minify'); + $this->newline = $this->options->__get('newlines'); + + $useContextOption = ! empty($this->options->context) + && (php_sapi_name() === 'cli' || $context['type'] === 'filter'); + + if ($context['type'] === 'file') { + $file = $context['data']; + $this->input->raw = $file; + if (! ($inputFile = Util::resolveUserPath($file, null, $this->docRoot))) { + throw new \Exception('Input file \'' . basename($file) . '\' not found.'); + } + $inputDir = $useContextOption + ? $this->options->context + : dirname($inputFile); + $this->resolveContext($inputDir, $inputFile); + } + elseif ($context['type'] === 'filter') { + if ($useContextOption) { + $this->resolveContext($this->options->context); + } + else { + $this->resolveContext(); + } + $this->input->string = $context['data']; + } + } + + public function release() + { + unset( + $this->tokens, + $this->mixins, + $this->references, + $this->cacheData, + $this->misc, + $this->plugins, + $this->aliases, + $this->selectorAliases + ); + } + + public function resolveContext($input_dir = null, $input_file = null) + { + if ($input_file) { + $this->input->path = $input_file; + $this->input->filename = basename($input_file); + $this->input->mtime = filemtime($input_file); + } + else { + $this->input->path = null; + $this->input->filename = null; + } + + $this->input->dir = $input_dir ?: $this->docRoot; + $this->input->dirUrl = substr($this->input->dir, strlen($this->docRoot)); + $this->output->dir = $this->io->getOutputDir(); + $this->output->filename = $this->io->getOutputFileName(); + $this->output->dirUrl = substr($this->output->dir, strlen($this->docRoot)); + + $context_resolved = true; + if ($input_file) { + $output_dir = $this->output->dir; + + if (! file_exists($output_dir)) { + warning("Output directory '$output_dir' doesn't exist."); + $context_resolved = false; + } + elseif (! is_writable($output_dir)) { + + debug('Attempting to change permissions.'); + + if (! @chmod($output_dir, 0755)) { + warning("Output directory '$output_dir' is unwritable."); + $context_resolved = false; + } + else { + debug('Permissions updated.'); + } + } + } + + $this->io->init(); + + return $context_resolved; + } + + + ############################# + # Boilerplate. + + protected function getBoilerplate() + { + $file = false; + $boilerplateOption = $this->options->boilerplate; + + if ($boilerplateOption === true) { + $file = Crush::$dir . '/boilerplate.txt'; + } + elseif (is_string($boilerplateOption)) { + if (file_exists($boilerplateOption)) { + $file = $boilerplateOption; + } + } + + // Return an empty string if no file is found. + if (! $file) { + return ''; + } + + $boilerplate = file_get_contents($file); + + // Substitute any tags + if (preg_match_all('~\{\{([^}]+)\}\}~', $boilerplate, $boilerplateMatches)) { + + // Command line arguments (if any). + $commandArgs = 'n/a'; + if (isset($_SERVER['argv'])) { + $argv = $_SERVER['argv']; + array_shift($argv); + $commandArgs = 'csscrush ' . implode(' ', $argv); + } + + $tags = [ + 'datetime' => @date('Y-m-d H:i:s O'), + 'year' => @date('Y'), + 'command' => $commandArgs, + 'plugins' => implode(',', $this->plugins), + 'version' => function () { + return Version::detect(); + }, + 'compile_time' => function () { + $now = microtime(true) - Crush::$process->stat['compile_start_time']; + return round($now, 4) . ' seconds'; + }, + ]; + + $replacements = []; + + foreach (array_keys($boilerplateMatches[0]) as $index) { + $tagName = trim($boilerplateMatches[1][$index]); + $replacement = '?'; + if (isset($tags[$tagName])) { + $replacement = is_callable($tags[$tagName]) ? $tags[$tagName]() : $tags[$tagName]; + } + $replacements[] = $replacement; + } + $boilerplate = str_replace($boilerplateMatches[0], $replacements, $boilerplate); + } + + // Pretty print. + $EOL = $this->newline; + $boilerplate = preg_split('~[\t]*'. Regex::$classes->newline . '[\t]*~', trim($boilerplate)); + $boilerplate = array_map('trim', $boilerplate); + $boilerplate = "$EOL * " . implode("$EOL * ", $boilerplate); + + return "/*$boilerplate$EOL */$EOL"; + } + + + ############################# + # Selector aliases. + + protected function resolveSelectorAliases() + { + $this->string->pregReplaceCallback( + Regex::make('~@selector(?:-(?alias|splat))? +\:?(?{{ident}}) +(?[^;]+) *;~iS'), + function ($m) { + $name = strtolower($m['name']); + $type = ! empty($m['type']) ? strtolower($m['type']) : 'alias'; + $handler = Util::stripCommentTokens($m['handler']); + Crush::$process->selectorAliases[$name] = new SelectorAlias($handler, $type); + }); + + // Create the selector aliases pattern and store it. + if ($this->selectorAliases) { + $names = implode('|', array_keys($this->selectorAliases)); + $this->selectorAliasesPatt + = Regex::make('~\:(' . $names . '){{RB}}(\()?~iS'); + } + } + + public function addSelectorAlias($name, $handler, $type = 'alias') + { + if ($type != 'callback') { + $handler = $this->tokens->capture($handler, 's'); + } + $this->selectorAliases[$name] = new SelectorAlias($handler, $type); + } + + + ############################# + # Aliases. + + protected function filterAliases() + { + // If a vendor target is given, we prune the aliases array. + $vendors = $this->options->vendor_target; + + // Default vendor argument, so use all aliases as normal. + if ('all' === $vendors) { + + return; + } + + // For expicit 'none' argument turn off aliases. + if ('none' === $vendors) { + $this->aliases = Crush::$config->bareAliases; + + return; + } + + // Normalize vendor names and create regex patt. + $vendor_names = (array) $vendors; + foreach ($vendor_names as &$vendor_name) { + $vendor_name = trim($vendor_name, '-'); + } + $vendor_patt = '~^\-(' . implode('|', $vendor_names) . ')\-~i'; + + + // Loop the aliases array, filter down to the target vendor. + foreach ($this->aliases as $section => $group_array) { + + // Declarations aliases. + if ($section === 'declarations') { + + foreach ($group_array as $property => $values) { + foreach ($values as $value => $prefix_values) { + foreach ($prefix_values as $index => $declaration) { + + if (in_array($declaration[2], $vendor_names)) { + continue; + } + + // Unset uneeded aliases. + unset($this->aliases[$section][$property][$value][$index]); + + if (empty($this->aliases[$section][$property][$value])) { + unset($this->aliases[$section][$property][$value]); + } + if (empty($this->aliases[$section][$property])) { + unset($this->aliases[$section][$property]); + } + } + } + } + } + + // Function group aliases. + elseif ($section === 'function_groups') { + + foreach ($group_array as $func_group => $vendors) { + foreach (array_keys($vendors) as $vendor) { + if (! in_array($vendor, $vendor_names)) { + unset($this->aliases['function_groups'][$func_group][$vendor]); + } + } + } + } + + // Everything else. + else { + foreach ($group_array as $alias_keyword => $prefix_array) { + + // Skip over pointers to function groups. + if ($prefix_array[0] === '.') { + continue; + } + + $result = []; + + foreach ($prefix_array as $prefix) { + if (preg_match($vendor_patt, $prefix)) { + $result[] = $prefix; + } + } + + // Prune the whole alias keyword if there is no result. + if (empty($result)) { + unset($this->aliases[$section][$alias_keyword]); + } + else { + $this->aliases[$section][$alias_keyword] = $result; + } + } + } + } + } + + + ############################# + # Plugins. + + protected function filterPlugins() + { + $this->plugins = array_unique($this->options->plugins); + + foreach ($this->plugins as $plugin) { + Crush::enablePlugin($plugin); + } + } + + + ############################# + # Variables. + + protected function captureVars() + { + Crush::$process->vars = Crush::$process->string->captureDirectives(['set', 'define'], [ + 'singles' => true, + 'lowercase_keys' => false, + ]) + Crush::$process->vars; + + // For convenience adding a runtime variable for cache busting linked resources. + $this->vars['timestamp'] = (int) $this->stat['compile_start_time']; + + // In-file variables override global variables. + $this->vars += Crush::$config->vars; + + // Runtime variables override in-file variables. + if (! empty($this->options->vars)) { + $this->vars = $this->options->vars + $this->vars; + } + + // Place variables referenced inside variables. + foreach ($this->vars as &$value) { + $this->placeVars($value); + } + } + + protected function placeAllVars() + { + $this->placeVars($this->string->raw); + + $rawTokens =& $this->tokens->store; + + // Repeat above steps for variables embedded in string tokens. + foreach ($rawTokens->s as $label => &$value) { + $this->placeVars($value); + } + + // Repeat above steps for variables embedded in URL tokens. + foreach ($rawTokens->u as $label => $url) { + if (! $url->isData && $this->placeVars($url->value)) { + // Re-evaluate $url->value if anything has been interpolated. + $url->evaluate(); + } + } + } + + protected function placeVars(&$value) + { + static $varFunction, $varFunctionSimple; + if (! $varFunction) { + $varFunctionSimple = Regex::make('~\$\( \s* ({{ ident }}) \s* \)~xS'); + $varFunction = new Functions(['$' => function ($rawArgs) { + $args = Functions::parseArgsSimple($rawArgs); + if (isset(Crush::$process->vars[$args[0]])) { + return Crush::$process->vars[$args[0]]; + } + else { + return isset($args[1]) ? $args[1] : ''; + } + }]); + } + + // Variables with no default value. + $value = preg_replace_callback($varFunctionSimple, function ($m) { + $varName = $m[1]; + if (isset(Crush::$process->vars[$varName])) { + return Crush::$process->vars[$varName]; + } + }, $value, -1, $varsPlaced); + + // Variables with default value. + if (strpos($value, '$(') !== false) { + + // Assume at least one replace. + $varsPlaced = true; + + // Variables may be nested so need to apply full function parsing. + $value = $varFunction->apply($value); + } + + // If we know replacements have been made we may want to update $value. e.g URL tokens. + return $varsPlaced; + } + + ############################# + # @for..in blocks. + + protected function resolveLoops() + { + $LOOP_VAR_PATT = '~\#\( \s* (?[a-zA-Z][\.a-zA-Z0-9-_]*) \s* \)~x'; + $LOOP_PATT = Regex::make('~ + (? + @for \s+ (?{{ident}}) \s+ in \s+ (?[^{]+) + ) \s* + {{ block }} + ~xiS'); + + $apply_scope = function ($str, $context) use ($LOOP_VAR_PATT, $LOOP_PATT) { + // Need to temporarily hide child block scopes. + $child_scopes = []; + $str = preg_replace_callback($LOOP_PATT, function ($m) use (&$child_scopes) { + $label = '?B' . count($child_scopes) . '?'; + $child_scopes[$label] = $m['block']; + return $m['expression'] . $label; + }, $str); + + $str = preg_replace_callback($LOOP_VAR_PATT, function ($m) use ($context) { + // Normalize casing of built-in loop variables. + // User variables are case-sensitive. + $arg = preg_replace_callback('~^loop\.(parent\.)?counter0?$~i', function ($m) { + return strtolower($m[0]); + }, $m['arg']); + + return isset($context[$arg]) ? $context[$arg] : ''; + }, $str); + + return str_replace(array_keys($child_scopes), array_values($child_scopes), $str); + }; + + $resolve_list = function ($list) { + // Resolve the list of items for iteration. + // Either a generator function or a plain list. + $items = []; + $this->placeVars($list); + $list = $this->functions->apply($list); + if (preg_match(Regex::make('~(?range){{ parens }}~ix'), $list, $m)) { + $func = strtolower($m['func']); + $args = Functions::parseArgs($m['parens_content']); + switch ($func) { + case 'range': + $items = range(...$args); + break; + } + } + else { + $items = Util::splitDelimList($list); + } + + return $items; + }; + + $unroll = function ($str, $context = []) use (&$unroll, $LOOP_PATT, $apply_scope, $resolve_list) { + $str = $apply_scope($str, $context); + while (preg_match($LOOP_PATT, $str, $m, PREG_OFFSET_CAPTURE)) { + $str = substr_replace($str, '', $m[0][1], strlen($m[0][0])); + $context['loop.parent.counter'] = isset($context['loop.counter']) ? $context['loop.counter'] : -1; + $context['loop.parent.counter0'] = isset($context['loop.counter0']) ? $context['loop.counter0'] : -1; + foreach ($resolve_list($m['list'][0]) as $index => $value) { + $str .= $unroll($m['block_content'][0], [ + $m['var'][0] => $value, + 'loop.counter' => $index + 1, + 'loop.counter0' => $index, + ] + $context); + } + } + + return $str; + }; + + $this->string->pregReplaceCallback($LOOP_PATT, function ($m) use ($unroll) { + return Template::tokenize($unroll(Template::unTokenize($m[0]))); + }); + } + + ############################# + # @ifdefine blocks. + + protected function resolveIfDefines() + { + $ifdefinePatt = Regex::make('~@if(?:set|define) \s+ (?not \s+)? (?{{ ident }}) \s* {{ parens }}? \s* \{~ixS'); + + $matches = $this->string->matchAll($ifdefinePatt); + + while ($match = array_pop($matches)) { + + $curlyMatch = new BalancedMatch($this->string, $match[0][1]); + + if (! $curlyMatch->match) { + continue; + } + + $negate = $match['negate'][1] != -1; + $nameDefined = isset($this->vars[$match['name'][0]]); + + $valueDefined = isset($match['parens_content'][0]); + $valueMatch = false; + if ($nameDefined && $valueDefined) { + $testValue = Util::rawValue(trim($match['parens_content'][0])); + $varValue = Util::rawValue($this->vars[$match['name'][0]]); + $valueMatch = $varValue == $testValue; + } + + if ( + ( $valueDefined && !$negate && $valueMatch ) + || ( $valueDefined && $negate && !$valueMatch ) + || ( !$valueDefined && !$negate && $nameDefined ) + || ( !$valueDefined && $negate && !$nameDefined ) + ) { + $curlyMatch->unWrap(); + } + else { + $curlyMatch->replace(''); + } + } + } + + + ############################# + # Mixins. + + protected function captureMixins() + { + $this->string->pregReplaceCallback(Regex::$patt->mixin, function ($m) { + Crush::$process->mixins[$m['name']] = new Mixin($m['block_content']); + }); + } + + + ############################# + # Fragments. + + protected function resolveFragments() + { + $fragments =& Crush::$process->fragments; + + $this->string->pregReplaceCallback(Regex::$patt->fragmentCapture, function ($m) use (&$fragments) { + $fragments[$m['name']] = new Fragment( + $m['block_content'], + ['name' => strtolower($m['name'])] + ); + return ''; + }); + + $this->string->pregReplaceCallback(Regex::$patt->fragmentInvoke, function ($m) use (&$fragments) { + $fragment = isset($fragments[$m['name']]) ? $fragments[$m['name']] : null; + if ($fragment) { + $args = []; + if (isset($m['parens'])) { + $args = Functions::parseArgs($m['parens_content']); + } + return $fragment($args); + } + return ''; + }); + } + + + ############################# + # Rules. + + public function captureRules() + { + $tokens = $this->tokens; + + $rulePatt = Regex::make('~ + (? {{ t_token }}) + \s* + (? [^{]+) + \s* + {{ block }} + ~xiS'); + $rulesAndMediaPatt = Regex::make('~{{ r_token }}|@media[^\{]+{{ block }}~iS'); + + $count = preg_match_all(Regex::$patt->t_token, $this->string->raw, $traceMatches, PREG_OFFSET_CAPTURE); + while ($count--) { + + $traceOffset = $traceMatches[0][$count][1]; + + preg_match($rulePatt, $this->string->raw, $ruleMatch, PREG_UNMATCHED_AS_NULL, $traceOffset); + + $selector = trim($ruleMatch['selector'] ?? ''); + $block = trim($ruleMatch['block_content'] ?? ''); + $replace = ''; + + // If rules are nested inside we set their parent property. + if (preg_match_all(Regex::$patt->r_token, $block, $childMatches)) { + + $block = preg_replace_callback($rulesAndMediaPatt, function ($m) use (&$replace) { + $replace .= $m[0]; + return ''; + }, $block); + + $rule = new Rule($selector, $block, $ruleMatch['trace_token']); + foreach ($childMatches[0] as $childToken) { + $childRule = $tokens->get($childToken); + if (! $childRule->parent) { + $childRule->parent = $rule; + } + } + } + else { + $rule = new Rule($selector, $block, $ruleMatch['trace_token'] ?? ''); + } + + $replace = $tokens->add($rule, 'r', $rule->label) . $replace; + + $this->string->splice($replace, $traceOffset, strlen($ruleMatch[0]) ?? ''); + } + + // Flip, since we just captured rules in reverse order. + $tokens->store->r = array_reverse($tokens->store->r); + + foreach ($tokens->store->r as $rule) { + if ($rule->parent) { + $rule->selectors->merge(array_keys($rule->parent->selectors->store)); + } + } + + // Cleanup unusable rules. + $this->string->pregReplaceCallback(Regex::$patt->r_token, function ($m) use ($tokens) { + $ruleToken = $m[0]; + $rule = $tokens->store->r[$ruleToken]; + if (empty($rule->declarations->store) && ! $rule->extendArgs) { + unset($tokens->store->r[$ruleToken]); + return ''; + } + return $ruleToken; + }); + } + + protected function processRules() + { + // Create table of name/selector to rule references. + $namedReferences = []; + + $previousRule = null; + foreach ($this->tokens->store->r as $rule) { + if ($rule->name) { + $namedReferences[$rule->name] = $rule; + } + foreach ($rule->selectors as $selector) { + $this->references[$selector->readableValue] = $rule; + } + if ($previousRule) { + $rule->previous = $previousRule; + $previousRule->next = $rule; + } + $previousRule = $rule; + } + + // Explicit named references take precedence. + $this->references = $namedReferences + $this->references; + + foreach ($this->tokens->store->r as $rule) { + + $rule->declarations->flatten(); + $rule->declarations->process(); + + $this->emit('rule_prealias', $rule); + + $rule->declarations->aliasProperties($rule->vendorContext); + $rule->declarations->aliasFunctions($rule->vendorContext); + $rule->declarations->aliasDeclarations($rule->vendorContext); + + $this->emit('rule_postalias', $rule); + + $rule->selectors->expand(); + $rule->applyExtendables(); + + $this->emit('rule_postprocess', $rule); + } + } + + + ############################# + # @-rule aliasing. + + protected function aliasAtRules() + { + if (empty($this->aliases['at-rules'])) { + + return; + } + + $aliases = $this->aliases['at-rules']; + $regex = Regex::$patt; + + foreach ($aliases as $at_rule => $at_rule_aliases) { + + $matches = $this->string->matchAll("~@$at_rule" . '[\s{]~i'); + + // Find at-rules that we want to alias. + while ($match = array_pop($matches)) { + + $curly_match = new BalancedMatch($this->string, $match[0][1]); + + if (! $curly_match->match) { + // Couldn't match the block. + continue; + } + + // Build up string with aliased blocks for splicing. + $original_block = $curly_match->whole(); + $new_blocks = []; + + foreach ($at_rule_aliases as $alias) { + + // Copy original block, replacing at-rule with alias name. + $copy_block = str_replace("@$at_rule", "@$alias", $original_block); + + // Aliases are nearly always prefixed, capture the current vendor name. + preg_match($regex->vendorPrefix, $alias, $vendor); + + $vendor = $vendor ? $vendor[1] : null; + + // Duplicate rules. + if (preg_match_all($regex->r_token, $copy_block, $copy_matches)) { + + $originals = []; + $replacements = []; + + foreach ($copy_matches[0] as $rule_label) { + + // Clone the matched rule. + $originals[] = $rule_label; + $clone_rule = clone $this->tokens->get($rule_label); + + $clone_rule->vendorContext = $vendor; + + // Store the clone. + $replacements[] = $this->tokens->add($clone_rule); + } + + // Finally replace the original labels with the cloned rule labels. + $copy_block = str_replace($originals, $replacements, $copy_block); + } + + // Add the copied block to the stack. + $new_blocks[] = $copy_block; + } + + // The original version is always pushed last in the list. + $new_blocks[] = $original_block; + + // Splice in the blocks. + $curly_match->replace(implode("\n", $new_blocks)); + } + } + } + + + ############################# + # Compile / collate. + + protected function collate() + { + $options = $this->options; + $minify = $options->minify; + $EOL = $this->newline; + + // Formatting replacements. + // Strip newlines added during processing. + $regex_replacements = []; + $regex_replacements['~\n+~'] = ''; + + if ($minify) { + // Strip whitespace around colons used in @-rule arguments. + $regex_replacements['~ ?\: ?~'] = ':'; + } + else { + // Pretty printing. + $regex_replacements['~}~'] = "$0$EOL$EOL"; + $regex_replacements['~([^\s])\{~'] = "$1 {"; + $regex_replacements['~ ?(@[^{]+\{)~'] = "$1$EOL"; + $regex_replacements['~ ?(@[^;]+\;)~'] = "$1$EOL"; + + // Trim leading spaces on @-rules and some tokens. + $regex_replacements[Regex::make('~ +([@}]|\?[rc]{{token_id}}\?)~S')] = "$1"; + + // Additional newline between adjacent rules and comments. + $regex_replacements[Regex::make('~({{r_token}}) (\s*) ({{c_token}})~xS')] = "$1$EOL$2$3"; + } + + // Apply all formatting replacements. + $this->string->pregReplaceHash($regex_replacements)->lTrim(); + + $this->string->restore('r'); + + // Record stats then drop rule objects to reclaim memory. + Crush::runStat('selector_count', 'rule_count', 'vars'); + $this->tokens->store->r = []; + + // If specified, apply advanced minification. + if (is_array($minify)) { + if (in_array('colors', $minify)) { + $this->minifyColors(); + } + } + + $this->decruft(); + + if (! $minify) { + // Add newlines after comments. + foreach ($this->tokens->store->c as $token => &$comment) { + $comment .= $EOL; + } + + // Insert comments and do final whitespace cleanup. + $this->string + ->restore('c') + ->trim() + ->append($EOL); + } + + // Insert URLs. + $urls = $this->tokens->store->u; + if ($urls) { + + $link = Util::getLinkBetweenPaths($this->output->dir, $this->input->dir); + $make_urls_absolute = $options->rewrite_import_urls === 'absolute'; + + foreach ($urls as $token => $url) { + + if ($url->isRelative && ! $url->noRewrite) { + if ($make_urls_absolute) { + $url->toRoot(); + } + // If output dir is different to input dir prepend a link between the two. + elseif ($link && $options->rewrite_import_urls) { + $url->prepend($link); + } + } + } + } + + if ($this->absoluteImports) { + $absoluteImports = ''; + $closing = $minify ? ';' : ";$EOL"; + foreach ($this->absoluteImports as $import) { + $absoluteImports .= "@import $import->url" . ($import->media ? " $import->media" : '') . $closing; + } + $this->string->prepend($absoluteImports); + } + + if ($options->boilerplate) { + $this->string->prepend($this->getBoilerplate()); + } + + if ($this->charset) { + $this->string->prepend("@charset \"$this->charset\";$EOL"); + } + + $this->string->restore(['u', 's']); + + if ($this->generateMap) { + $this->generateSourceMap(); + } + } + + private $iniOriginal = []; + public function preCompile() + { + foreach ([ + 'pcre.backtrack_limit' => 1000000, + 'pcre.jit' => 0, // Have run into PREG_JIT_STACKLIMIT_ERROR (issue #82). + 'memory_limit' => '128M', + ] as $name => $value) { + $this->iniOriginal[$name] = ini_get($name); + if ($name === 'memory_limit' && $this->returnBytes(ini_get($name)) > $this->returnBytes($value)) { + continue; + } + ini_set($name, $value); + } + + $this->filterPlugins(); + $this->filterAliases(); + + $this->functions->setPattern(true); + + $this->stat['compile_start_time'] = microtime(true); + } + + private function returnBytes(string $value) + { + $value = trim($value); + $last = strtolower($value[strlen($value) - 1]); + $value = (float) $value; + + switch ($last) { + // The 'G' modifier is available + case 'g': + $value *= 1024; + case 'm': + $value *= 1024; + case 'k': + $value *= 1024; + } + + return $value; + } + + public function postCompile() + { + $this->release(); + + Crush::runStat('compile_time'); + + foreach ($this->iniOriginal as $name => $value) { + ini_set($name, $value); + } + } + + public function compile() + { + $this->preCompile(); + + $importer = new Importer($this); + $this->string = new StringObject($importer->collate()); + + // Capture phase 0 hook: Before all variables have resolved. + $this->emit('capture_phase0', $this); + + $this->captureVars(); + + $this->resolveIfDefines(); + + $this->resolveLoops(); + + $this->placeAllVars(); + + // Capture phase 1 hook: After all variables have resolved. + $this->emit('capture_phase1', $this); + + $this->resolveSelectorAliases(); + + $this->captureMixins(); + + $this->resolveFragments(); + + // Capture phase 2 hook: After most built-in directives have resolved. + $this->emit('capture_phase2', $this); + + $this->captureRules(); + + // Calling functions on media query lists. + $process = $this; + $this->string->pregReplaceCallback('~@media\s+(?[^{]+)\{~i', function ($m) use (&$process) { + return "@media {$process->functions->apply($m['media_list'])}{"; + }); + + $this->aliasAtRules(); + + $this->processRules(); + + $this->collate(); + + $this->postCompile(); + + return $this->string; + } + + + ############################# + # Source maps. + + public function generateSourceMap() + { + $this->sourceMap = [ + 'version' => 3, + 'file' => $this->output->filename, + 'sources' => [], + ]; + foreach ($this->sources as $source) { + $this->sourceMap['sources'][] = Util::getLinkBetweenPaths($this->output->dir, $source, false); + } + + $token_patt = Regex::make('~\?[tm]{{token_id}}\?~S'); + $mappings = []; + $lines = preg_split(Regex::$patt->newline, $this->string->raw); + $tokens =& $this->tokens->store; + + // All mappings are calculated as delta values. + $previous_dest_col = 0; + $previous_src_file = 0; + $previous_src_line = 0; + $previous_src_col = 0; + + foreach ($lines as &$line_text) { + + $line_segments = []; + + while (preg_match($token_patt, $line_text, $m, PREG_OFFSET_CAPTURE)) { + + list($token, $dest_col) = $m[0]; + $token_type = $token[1]; + + if (isset($tokens->{$token_type}[$token])) { + + list($src_file, $src_line, $src_col) = explode(',', $tokens->{$token_type}[$token]); + $line_segments[] = + Util::vlqEncode($dest_col - $previous_dest_col) . + Util::vlqEncode($src_file - $previous_src_file) . + Util::vlqEncode($src_line - $previous_src_line) . + Util::vlqEncode($src_col - $previous_src_col); + + $previous_dest_col = $dest_col; + $previous_src_file = $src_file; + $previous_src_line = $src_line; + $previous_src_col = $src_col; + } + $line_text = substr_replace($line_text, '', $dest_col, strlen($token)); + } + + $mappings[] = implode(',', $line_segments); + } + + $this->string->raw = implode($this->newline, $lines); + $this->sourceMap['mappings'] = implode(';', $mappings); + } + + + ############################# + # Decruft. + + protected function decruft() + { + return $this->string->pregReplaceHash([ + + // Strip leading zeros on floats. + '~([: \(,])(-?)0(\.\d+)~S' => '$1$2$3', + + // Strip unnecessary units on zero values for length types. + '~([: \(,])\.?0' . Regex::$classes->length_unit . '~iS' => '${1}0', + + // Collapse zero lists. + '~(\: *)(?:0 0 0|0 0 0 0) *([;}])~S' => '${1}0$2', + + // Collapse zero lists 2nd pass. + '~(padding|margin|border-radius) ?(\: *)0 0 *([;}])~iS' => '${1}${2}0$3', + + // Dropping redundant trailing zeros on TRBL lists. + '~(\: *)(-?(?:\d+)?\.?\d+[a-z]{1,4}) 0 0 0 *([;}])~iS' => '$1$2 0 0$3', + '~(\: *)0 0 (-?(?:\d+)?\.?\d+[a-z]{1,4}) 0 *([;}])~iS' => '${1}0 0 $2$3', + + // Compress hex codes. + Regex::$patt->cruftyHex => '#$1$2$3', + ]); + } + + + ############################# + # Advanced minification. + + protected function minifyColors() + { + static $keywords_patt, $functions_patt; + + $minified_keywords = Color::getMinifyableKeywords(); + + if (! $keywords_patt) { + $keywords_patt = '~(?string->pregReplaceCallback($keywords_patt, function ($m) use ($minified_keywords) { + return $minified_keywords[strtolower($m[0])]; + }); + + $this->string->pregReplaceCallback($functions_patt, function ($m) { + $args = Functions::parseArgs(trim($m[2])); + if (stripos($m[1], 'hsl') === 0) { + $args = Color::cssHslToRgb($args); + } + return Color::rgbToHex($args); + }); + } +} diff --git a/lib/CssCrush/Regex.php b/lib/CssCrush/Regex.php new file mode 100644 index 0000000..deba844 --- /dev/null +++ b/lib/CssCrush/Regex.php @@ -0,0 +1,112 @@ +ident = '[a-zA-Z0-9_-]+'; + $classes->number = '[+-]?\d*\.?\d+'; + $classes->percentage = $classes->number . '%'; + $classes->length_unit = '(?i)(?:e[mx]|c[hm]|rem|v[hwm]|in|p[tcx])(?-i)'; + $classes->length = $classes->number . $classes->length_unit; + $classes->color_hex = '#[[:xdigit:]]{3}(?:[[:xdigit:]]{3})?'; + + // Tokens. + $classes->token_id = '[0-9a-z]+'; + $classes->c_token = '\?c' . $classes->token_id . '\?'; // Comments. + $classes->s_token = '\?s' . $classes->token_id . '\?'; // Strings. + $classes->r_token = '\?r' . $classes->token_id . '\?'; // Rules. + $classes->u_token = '\?u' . $classes->token_id . '\?'; // URLs. + $classes->t_token = '\?t' . $classes->token_id . '\?'; // Traces. + $classes->a_token = '\?a(' . $classes->token_id . ')\?'; // Args. + + // Boundries. + $classes->LB = '(?RB = '(?![\w-])'; // Right ident boundry. + + // Recursive block matching. + $classes->block = '(?\{\s*(?(?:(?>[^{}]+)|(?&block))*)\})'; + $classes->parens = '(?\(\s*(?(?:(?>[^()]+)|(?&parens))*)\))'; + + // Misc. + $classes->vendor = '-[a-zA-Z]+-'; + $classes->hex = '[[:xdigit:]]'; + $classes->newline = '(\r\n?|\n)'; + + // Create standalone class patterns, add classes as class swaps. + foreach ($classes as $name => $class) { + $patt->{$name} = '~' . $class . '~S'; + } + + // Rooted classes. + $patt->rooted_ident = '~^' . $classes->ident . '$~'; + $patt->rooted_number = '~^' . $classes->number . '$~'; + + // @-rules. + $patt->import = Regex::make('~@import \s+ ({{u_token}}) \s? ([^;]*);~ixS'); + $patt->charset = Regex::make('~@charset \s+ ({{s_token}}) \s*;~ixS'); + $patt->mixin = Regex::make('~@mixin \s+ (?{{ident}}) \s* {{block}}~ixS'); + $patt->fragmentCapture = Regex::make('~@fragment \s+ (?{{ident}}) \s* {{block}}~ixS'); + $patt->fragmentInvoke = Regex::make('~@fragment \s+ (?{{ident}}) {{parens}}? \s* ;~ixS'); + $patt->abstract = Regex::make('~^@abstract \s+ (?{{ident}})~ixS'); + + // Functions. + $patt->functionTest = Regex::make('~{{ LB }} (?{{ ident }}) \(~xS'); + $patt->thisFunction = Functions::makePattern(['this']); + + // Strings and comments. + $patt->string = '~(\'|")(?:\\\\\1|[^\1])*?\1~xS'; + $patt->commentAndString = '~ + # Quoted string (to EOF if unmatched). + (\'|"|`)(?:\\\\\1|[^\1])*?(?:\1|$) + | + # Block comment (to EOF if unmatched). + /\*(?:[^*]*\*+(?:[^/*][^*]*\*+)*/|.*) + ~xsS'; + + // Misc. + $patt->vendorPrefix = '~^-([a-z]+)-([a-z-]+)~iS'; + $patt->ruleDirective = '~^(?:(@include)|(@extends?)|(@name))[\s]+~iS'; + $patt->argListSplit = '~\s*[,\s]\s*~S'; + $patt->cruftyHex = Regex::make('~\#({{hex}})\1({{hex}})\2({{hex}})\3~S'); + $patt->token = Regex::make('~^ \? (?[a-zA-Z]) {{token_id}} \? $~xS'); + } + + public static function make($pattern) + { + static $cache = []; + + if (isset($cache[$pattern])) { + return $cache[$pattern]; + } + + return $cache[$pattern] = preg_replace_callback('~\{\{ *(?\w+) *\}\}~S', function ($m) { + return Regex::$classes->{ $m['name'] }; + }, $pattern); + } + + public static function matchAll($patt, $subject, $offset = 0) + { + $count = preg_match_all($patt, $subject, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER, $offset); + + return $count ? $matches : []; + } +} + +Regex::init(); diff --git a/lib/CssCrush/Rule.php b/lib/CssCrush/Rule.php new file mode 100644 index 0000000..2e899a5 --- /dev/null +++ b/lib/CssCrush/Rule.php @@ -0,0 +1,145 @@ +label = $process->tokens->createLabel('r'); + $this->marker = $process->generateMap ? $traceToken : null; + $this->selectors = new SelectorList($selectorString, $this); + $this->declarations = new DeclarationList($declarationsString, $this); + } + + public function __toString() + { + $process = Crush::$process; + + // Merge the extend selectors. + $this->selectors->store += $this->extendSelectors; + + // Dereference and return empty string if there are no selectors or declarations. + if (empty($this->selectors->store) || empty($this->declarations->store)) { + $process->tokens->pop($this->label); + + return ''; + } + + $stub = $this->marker; + + if ($process->minifyOutput) { + return "$stub{$this->selectors->join()}{{$this->declarations->join()}}"; + } + else { + return $stub . call_user_func($process->ruleFormatter, $this); + } + } + + public function __clone() + { + $this->selectors = clone $this->selectors; + $this->declarations = clone $this->declarations; + } + + + ############################# + # Rule inheritance. + + public function addExtendSelectors($rawValue) + { + foreach (Util::splitDelimList($rawValue) as $arg) { + $extendArg = new ExtendArg($arg); + $this->extendArgs[$extendArg->raw] = $extendArg; + } + } + + public function resolveExtendables() + { + if (! $this->extendArgs) { + + return false; + } + elseif (! $this->resolvedExtendables) { + + $references =& Crush::$process->references; + + // Filter the extendArgs list to usable references. + $filtered = []; + foreach ($this->extendArgs as $extendArg) { + + if (isset($references[$extendArg->name])) { + $parentRule = $references[$extendArg->name]; + $parentRule->resolveExtendables(); + $extendArg->pointer = $parentRule; + $filtered[$parentRule->label] = $extendArg; + } + } + + $this->resolvedExtendables = true; + $this->extendArgs = $filtered; + } + + return true; + } + + public function applyExtendables() + { + if (! $this->resolveExtendables()) { + + return; + } + + // Create a stack of all parent rule args. + $parentExtendArgs = []; + foreach ($this->extendArgs as $extendArg) { + $parentExtendArgs += $extendArg->pointer->extendArgs; + } + + // Merge this rule's extendArgs with parent extendArgs. + $this->extendArgs += $parentExtendArgs; + + // Add this rule's selectors to all extendArgs. + foreach ($this->extendArgs as $extendArg) { + + $ancestor = $extendArg->pointer; + + $extendSelectors = $this->selectors->store; + + // If there is a pseudo class extension create a new set accordingly. + if ($extendArg->pseudo) { + + $extendSelectors = []; + foreach ($this->selectors->store as $selector) { + $newSelector = clone $selector; + $newReadable = $newSelector->appendPseudo($extendArg->pseudo); + $extendSelectors[$newReadable] = $newSelector; + } + } + $ancestor->extendSelectors += $extendSelectors; + } + } +} diff --git a/lib/CssCrush/Selector.php b/lib/CssCrush/Selector.php new file mode 100644 index 0000000..108ceb3 --- /dev/null +++ b/lib/CssCrush/Selector.php @@ -0,0 +1,105 @@ +allowPrefix = false; + } + + $this->readableValue = Selector::makeReadable($rawSelector); + + $this->value = Selector::expandAliases($rawSelector); + } + + public function __toString() + { + if (Crush::$process->minifyOutput) { + // Trim whitespace around selector combinators. + $this->value = preg_replace('~ ?([>\~+]) ?~S', '$1', $this->value); + } + else { + $this->value = Selector::normalizeWhiteSpace($this->value); + } + return $this->value; + } + + public function appendPseudo($pseudo) + { + // Check to avoid doubling-up. + if (! StringObject::endsWith($this->readableValue, $pseudo)) { + + $this->readableValue .= $pseudo; + $this->value .= $pseudo; + } + return $this->readableValue; + } + + public static function normalizeWhiteSpace($str) + { + // Create space around combinators, then normalize whitespace. + return Util::normalizeWhiteSpace(preg_replace('~([>+]|\~(?!=))~S', ' $1 ', $str)); + } + + public static function makeReadable($str) + { + $str = Selector::normalizeWhiteSpace($str); + + // Quick test for string tokens. + if (strpos($str, '?s') !== false) { + $str = Crush::$process->tokens->restore($str, 's'); + } + + return $str; + } + + public static function expandAliases($str) + { + $process = Crush::$process; + + if (! $process->selectorAliases || ! preg_match($process->selectorAliasesPatt, $str)) { + return $str; + } + + while (preg_match_all($process->selectorAliasesPatt, $str, $m, PREG_OFFSET_CAPTURE | PREG_SET_ORDER)) { + + $alias_call = end($m); + $alias_name = strtolower($alias_call[1][0]); + + $start = $alias_call[0][1]; + $length = strlen($alias_call[0][0]); + $args = []; + + // It's a function alias if a start paren is matched. + if (isset($alias_call[2])) { + + // Parse argument list. + if (preg_match(Regex::$patt->parens, $str, $parens, PREG_OFFSET_CAPTURE, $start)) { + $args = Functions::parseArgs($parens[2][0]); + + // Amend offsets. + $paren_start = $parens[0][1]; + $paren_len = strlen($parens[0][0]); + $length = ($paren_start + $paren_len) - $start; + } + } + + $str = substr_replace($str, $process->selectorAliases[$alias_name]($args), $start, $length); + } + + return $str; + } +} diff --git a/lib/CssCrush/SelectorAlias.php b/lib/CssCrush/SelectorAlias.php new file mode 100644 index 0000000..8189d62 --- /dev/null +++ b/lib/CssCrush/SelectorAlias.php @@ -0,0 +1,63 @@ +handler = $handler; + $this->type = $type; + + switch ($this->type) { + case 'alias': + $this->handler = new Template($handler); + break; + } + } + + public function __invoke($args) + { + $handler = $this->handler; + $tokens = Crush::$process->tokens; + + $splat_arg_patt = Regex::make('~#\((?{{ ident }})?\)~'); + + switch ($this->type) { + case 'alias': + return $handler($args); + case 'callback': + $template = new Template($handler($args)); + return $template($args); + case 'splat': + $handler = $tokens->restore($handler, 's'); + if ($args) { + $list = []; + foreach ($args as $arg) { + $list[] = SelectorAlias::wrap( + $tokens->capture(preg_replace($splat_arg_patt, $arg, $handler), 's') + ); + } + $handler = implode(',', $list); + } + else { + $handler = $tokens->capture(preg_replace_callback($splat_arg_patt, function ($m) { + return $m['fallback']; + }, $handler), 's'); + } + return SelectorAlias::wrap($handler); + } + } + + public static function wrap($str) + { + return strpos($str, ',') !== false ? ":any($str)" : $str; + } +} diff --git a/lib/CssCrush/SelectorList.php b/lib/CssCrush/SelectorList.php new file mode 100644 index 0000000..87ec177 --- /dev/null +++ b/lib/CssCrush/SelectorList.php @@ -0,0 +1,136 @@ +abstract, $selector, $m)) { + $rule->name = strtolower($m['name']); + $rule->isAbstract = true; + } + else { + $this->add(new Selector($selector)); + } + } + } + + public function add(Selector $selector) + { + $this->store[$selector->readableValue] = $selector; + } + + public function join($glue = ',') + { + return implode($glue, $this->store); + } + + public function expand() + { + static $grouping_patt, $expand, $expandSelector; + if (! $grouping_patt) { + + $grouping_patt = Regex::make('~\:any{{ parens }}~iS'); + + $expand = function ($selector_string) use ($grouping_patt) + { + if (preg_match($grouping_patt, $selector_string, $m, PREG_OFFSET_CAPTURE)) { + + list($full_match, $full_match_offset) = $m[0]; + $before = substr($selector_string, 0, $full_match_offset); + $after = substr($selector_string, strlen($full_match) + $full_match_offset); + $selectors = []; + + // Allowing empty strings for more expansion possibilities. + foreach (Util::splitDelimList($m['parens_content'][0], ['allow_empty_strings' => true]) as $segment) { + if ($selector = trim("$before$segment$after")) { + $selectors[$selector] = true; + } + } + + return $selectors; + } + + return false; + }; + + $expandSelector = function ($selector_string) use ($expand) + { + if ($running_stack = $expand($selector_string)) { + + $flattened_stack = []; + do { + $loop_stack = []; + foreach ($running_stack as $selector => $bool) { + $selectors = $expand($selector); + if (! $selectors) { + $flattened_stack += [$selector => true]; + } + else { + $loop_stack += $selectors; + } + } + $running_stack = $loop_stack; + + } while ($loop_stack); + + return $flattened_stack; + } + + return [$selector_string => true]; + }; + } + + $expanded_set = []; + + foreach ($this->store as $original_selector) { + if (stripos($original_selector->value, ':any(') !== false) { + foreach ($expandSelector($original_selector->value) as $selector_string => $bool) { + $new = new Selector($selector_string); + $expanded_set[$new->readableValue] = $new; + } + } + else { + $expanded_set[$original_selector->readableValue] = $original_selector; + } + } + + $this->store = $expanded_set; + } + + public function merge($rawSelectors) + { + $stack = []; + + foreach ($rawSelectors as $rawParentSelector) { + foreach ($this->store as $selector) { + + $useParentSymbol = strpos($selector->value, '&') !== false; + + if (! $selector->allowPrefix && ! $useParentSymbol) { + $stack[$selector->readableValue] = $selector; + } + elseif ($useParentSymbol) { + $new = new Selector(str_replace('&', $rawParentSelector, $selector->value)); + $stack[$new->readableValue] = $new; + } + else { + $new = new Selector("$rawParentSelector {$selector->value}"); + $stack[$new->readableValue] = $new; + } + } + } + $this->store = $stack; + } +} diff --git a/lib/CssCrush/StringObject.php b/lib/CssCrush/StringObject.php new file mode 100644 index 0000000..66a6a58 --- /dev/null +++ b/lib/CssCrush/StringObject.php @@ -0,0 +1,158 @@ +raw = $str; + } + + public function __toString() + { + return $this->raw; + } + + public static function endsWith($haystack, $needle) + { + return substr($haystack, -strlen($needle)) === $needle; + } + + public function update($str) + { + $this->raw = $str; + + return $this; + } + + public function substr($start, $length = null) + { + if (! isset($length)) { + + return substr($this->raw, $start); + } + else { + + return substr($this->raw, $start, $length); + } + } + + public function matchAll($patt, $offset = 0) + { + return Regex::matchAll($patt, $this->raw, $offset); + } + + public function replaceHash($replacements) + { + if ($replacements) { + $this->raw = str_replace( + array_keys($replacements), + array_values($replacements), + $this->raw); + } + return $this; + } + + public function pregReplaceHash($replacements) + { + if ($replacements) { + $this->raw = preg_replace( + array_keys($replacements), + array_values($replacements), + $this->raw); + } + return $this; + } + + public function pregReplaceCallback($patt, $callback) + { + $this->raw = preg_replace_callback($patt, $callback, $this->raw); + return $this; + } + + public function append($append) + { + $this->raw .= $append; + return $this; + } + + public function prepend($prepend) + { + $this->raw = $prepend . $this->raw; + return $this; + } + + public function splice($replacement, $offset, $length = null) + { + $this->raw = substr_replace($this->raw, $replacement, $offset, $length); + return $this; + } + + public function trim() + { + $this->raw = trim($this->raw); + return $this; + } + + public function rTrim() + { + $this->raw = rtrim($this->raw); + return $this; + } + + public function lTrim() + { + $this->raw = ltrim($this->raw); + return $this; + } + + public function restore($types, $release = false, $callback = null) + { + $this->raw = Crush::$process->tokens->restore($this->raw, $types, $release, $callback); + + return $this; + } + + public function captureDirectives($directive, $parse_options = []) + { + if (is_array($directive)) { + $directive = '(?:' . implode('|', $directive) . ')'; + } + + $parse_options += [ + 'keyed' => true, + 'lowercase_keys' => true, + 'ignore_directives' => true, + 'singles' => false, + 'flatten' => false, + ]; + + if ($parse_options['singles']) { + $patt = Regex::make('~@(?i)' . $directive . '(?-i)(?:\s*{{ block }}|\s+(?{{ ident }})\s+(?[^;]+)\s*;)~S'); + } + else { + $patt = Regex::make('~@(?i)' . $directive . '(?-i)\s*{{ block }}~S'); + } + + $captured_directives = []; + $this->pregReplaceCallback($patt, function ($m) use (&$captured_directives, $parse_options) { + if (isset($m['name'])) { + $name = $parse_options['lowercase_keys'] ? strtolower($m['name']) : $m['name']; + $captured_directives[$name] = $m['value']; + } + else { + $captured_directives = DeclarationList::parse($m['block_content'], $parse_options) + $captured_directives; + } + return ''; + }); + + return $captured_directives; + } +} diff --git a/lib/CssCrush/Template.php b/lib/CssCrush/Template.php new file mode 100644 index 0000000..a459c8b --- /dev/null +++ b/lib/CssCrush/Template.php @@ -0,0 +1,149 @@ +defaults[$position] = $defaultValue; + } + + // Update argument count. + $argNumber = ((int) $position) + 1; + $self->argCount = max($self->argCount, $argNumber); + + return "?a$position?"; + }; + + $templateFunctions->register['#'] = $captureCallback; + + $this->string = $templateFunctions->apply($str); + } + + public function __invoke(?array $args = null, $str = null) + { + $str = isset($str) ? $str : $this->string; + + // Apply passed arguments as priority. + if (isset($args)) { + + list($find, $replace) = $this->prepare($args, false); + } + + // Secondly use prepared substitutions if available. + elseif ($this->substitutions) { + + list($find, $replace) = $this->substitutions; + } + + // Apply substitutions. + if (isset($find) && isset($replace)) { + $str = str_replace($find, $replace, $str); + } + + return Template::tokenize($str); + } + + public function getArgValue($index, &$args) + { + // First lookup a passed value. + if (isset($args[$index]) && $args[$index] !== 'default') { + + return $args[$index]; + } + + // Get a default value. + $default = isset($this->defaults[$index]) ? $this->defaults[$index] : ''; + + // Recurse for nested arg() calls. + while (preg_match(Regex::$patt->a_token, $default, $m)) { + $default = str_replace( + $m[0], + $this->getArgValue((int) $m[1], $args), + $default); + } + + return $default; + } + + public function prepare(array $args, $persist = true) + { + // Create table of substitutions. + $find = []; + $replace = []; + + if ($this->argCount) { + + $argIndexes = range(0, $this->argCount-1); + + foreach ($argIndexes as $index) { + $find[] = "?a$index?"; + $replace[] = $this->getArgValue($index, $args); + } + } + + $substitutions = [$find, $replace]; + + // Persist substitutions by default. + if ($persist) { + $this->substitutions = $substitutions; + } + + return $substitutions; + } + + public static function tokenize($str) + { + $str = Crush::$process->tokens->capture($str, 's'); + $str = Crush::$process->tokens->capture($str, 'u'); + + return $str; + } + + public static function unTokenize($str) + { + $str = Crush::$process->tokens->restore($str, ['u', 's']); + + return $str; + } +} diff --git a/lib/CssCrush/Tokens.php b/lib/CssCrush/Tokens.php new file mode 100644 index 0000000..f40510f --- /dev/null +++ b/lib/CssCrush/Tokens.php @@ -0,0 +1,161 @@ +store = new \stdClass; + $this->ids = new \stdClass; + + foreach ($types as $type) { + $this->store->$type = []; + $this->ids->$type = 0; + } + } + + public function get($label) + { + $path =& $this->store->{$label[1]}; + + return isset($path[$label]) ? $path[$label] : null; + } + + public function pop($label) + { + $value = $this->get($label); + if (isset($value)) { + unset($this->store->{$label[1]}[$label]); + } + + return $value; + } + + public function add($value, $type = null, $existing_label = null) + { + if ($value instanceof Url) { + $type = 'u'; + } + elseif ($value instanceof Rule) { + $type = 'r'; + } + $label = $existing_label ? $existing_label : $this->createLabel($type); + $this->store->{$type}[$label] = $value; + + return $label; + } + + public function createLabel($type) + { + $counter = base_convert(++$this->ids->$type, 10, 36); + + return "?$type$counter?"; + } + + public function restore($str, $types, $release = false, $callback = null) + { + $types = implode('', (array) $types); + $patt = Regex::make("~\?[$types]{{ token_id }}\?~S"); + $tokens = $this; + $callback = $callback ?: function ($m) use ($tokens, $release) { + return $release ? $tokens->pop($m[0]) : $tokens->get($m[0]); + }; + + return preg_replace_callback($patt, $callback, $str); + } + + public function capture($str, $type) + { + switch ($type) { + case 'u': + return $this->captureUrls($str); + break; + case 's': + return preg_replace_callback(Regex::$patt->string, function ($m) { + return Crush::$process->tokens->add($m[0], 's'); + }, $str); + } + } + + public function captureUrls($str, $add_padding = false) + { + $count = preg_match_all( + Regex::make('~@import \s+ (?{{s_token}}) | {{LB}} (?url|data-uri) {{parens}}~ixS'), + $str, + $m, + PREG_OFFSET_CAPTURE); + + while ($count--) { + + list($full_text, $full_offset) = $m[0][$count]; + list($import_text, $import_offset) = $m['import'][$count]; + + // @import directive. + if ($import_offset !== -1) { + + $label = $this->add(new Url(trim($import_text))); + $str = str_replace($import_text, $add_padding ? str_pad($label, strlen($import_text)) : $label, $str); + } + + // A URL function. + else { + $func_name = strtolower($m['func'][$count][0]); + + $url = new Url(trim($m['parens_content'][$count][0])); + $url->convertToData = 'data-uri' === $func_name; + $label = $this->add($url); + $str = substr_replace( + $str, + $add_padding ? Tokens::pad($label, $full_text) : $label, + $full_offset, + strlen($full_text)); + } + } + + return $str; + } + + public static function pad($label, $replaced_text) + { + // Padding token labels to maintain whitespace and newlines. + if (($last_newline_pos = strrpos($replaced_text, "\n")) !== false) { + $label .= str_repeat("\n", substr_count($replaced_text, "\n")) . str_repeat(' ', strlen(substr($replaced_text, $last_newline_pos))-1); + } + else { + $label = str_pad($label, strlen($replaced_text)); + } + + return $label; + } + + public static function is($label, $of_type) + { + if (preg_match(Regex::$patt->token, $label, $m)) { + + return $of_type ? ($of_type === $m['type']) : true; + } + + return false; + } + + public static function test($value) + { + return preg_match(Regex::$patt->token, $value, $m) ? $m['type'] : false; + } +} diff --git a/lib/CssCrush/Url.php b/lib/CssCrush/Url.php new file mode 100644 index 0000000..cbbcd92 --- /dev/null +++ b/lib/CssCrush/Url.php @@ -0,0 +1,224 @@ +s_token, $raw_value)) { + $this->value = trim(Crush::$process->tokens->pop($raw_value), '\'"'); + } + else { + $this->value = $raw_value; + } + + $this->originalValue = $this->value; + $this->evaluate(); + } + + public function __toString() + { + if ($this->convertToData) { + $this->toData(); + } + + if ($this->isRelative || $this->isRooted) { + $this->simplify(); + } + + if ($this->isData) { + return 'url("http://github.com/'%20.%20preg_replace('~(?%3C!\x5c)"~', '\\"', $this->value) . '")'; + } + + // Only wrap url with quotes if it contains tricky characters. + $quote = ''; + if (preg_match('~[()*\s]~S', $this->value)) { + $quote = '"'; + } + + return "url(http://github.com/$quote$this-%3Evalue$quote)"; + } + + public function update($new_value) + { + $this->value = $new_value; + + return $this->evaluate(); + } + + public function evaluate() + { + // Protocol, protocol-relative (//) or fragment URL. + if (preg_match('~^(?: (?[a-z]+)\: | \/{2} | \# )~ix', $this->value, $m)) { + + $this->protocol = ! empty($m['protocol']) ? strtolower($m['protocol']) : 'relative'; + + switch ($this->protocol) { + case 'data': + $type = 'data'; + break; + default: + $type = 'absolute'; + break; + } + } + // Relative and rooted URLs. + else { + $type = 'relative'; + $leading_variable = strpos($this->value, '$(') === 0; + + // Normalize './' led paths. + $this->value = preg_replace('~^\.\/+~i', '', $this->value); + + if ($leading_variable || ($this->value !== '' && $this->value[0] === '/')) { + $type = 'rooted'; + } + + // Normalize slashes. + $this->value = rtrim(preg_replace('~[\\\\/]+~', '/', $this->value), '/'); + } + + $this->setType($type); + + return $this; + } + + public function isRelativeImplicit() + { + return $this->isRelative && preg_match('~^([\w$-]|\.[^\/.])~', $this->originalValue); + } + + public function getAbsolutePath() + { + $path = false; + if ($this->protocol) { + $path = $this->value; + } + elseif ($this->isRelative || $this->isRooted) { + $path = Crush::$process->docRoot . + ($this->isRelative ? $this->toRoot()->simplify()->value : $this->value); + } + return $path; + } + + public function prepend($path_fragment) + { + if ($this->isRelative) { + $this->value = rtrim($path_fragment, DIRECTORY_SEPARATOR) + . DIRECTORY_SEPARATOR + . ltrim($this->value, DIRECTORY_SEPARATOR); + } + + return $this; + } + + public function toRoot() + { + if ($this->isRelative) { + $this->prepend(Crush::$process->input->dirUrl . '/'); + $this->setType('rooted'); + } + + return $this; + } + + public function toData() + { + // Only make one conversion attempt. + $this->convertToData = false; + + $file = Crush::$process->docRoot . $this->toRoot()->value; + + // File not found. + if (! file_exists($file)) { + + return $this; + } + + $file_ext = pathinfo($file, PATHINFO_EXTENSION); + + // Only allow certain extensions + static $allowed_file_extensions = [ + 'woff' => 'application/x-font-woff;charset=utf-8', + 'ttf' => 'font/truetype;charset=utf-8', + 'svg' => 'image/svg+xml', + 'svgz' => 'image/svg+xml', + 'gif' => 'image/gif', + 'jpeg' => 'image/jpg', + 'jpg' => 'image/jpg', + 'png' => 'image/png', + ]; + + if (! isset($allowed_file_extensions[$file_ext])) { + + return $this; + } + + $mime_type = $allowed_file_extensions[$file_ext]; + $file_contents = file_get_contents($file); + + if ($file_ext === 'svg') { + $string = preg_replace('/\R/', '%0A', trim($file_contents)); + $this->value = "data:$mime_type;utf8,$string"; + } + else { + $base64 = base64_encode($file_contents); + $this->value = "data:$mime_type;base64,$base64"; + } + + $this->setType('data')->protocol = 'data'; + + return $this; + } + + public function setType($type = 'absolute') + { + $this->isAbsolute = false; + $this->isRooted = false; + $this->isRelative = false; + $this->isData = false; + + switch ($type) { + case 'absolute': + $this->isAbsolute = true; + break; + case 'relative': + $this->isRelative = true; + break; + case 'rooted': + $this->isRooted = true; + break; + case 'data': + $this->isData = true; + $this->convertToData = false; + break; + } + + return $this; + } + + public function simplify() + { + if ($this->isRelative || $this->isRooted) { + $this->value = Util::simplifyPath($this->value); + } + return $this; + } +} diff --git a/lib/CssCrush/Util.php b/lib/CssCrush/Util.php new file mode 100644 index 0000000..1e3aadf --- /dev/null +++ b/lib/CssCrush/Util.php @@ -0,0 +1,279 @@ + $b_index ? 1 : -1; + } + elseif ($a_found && ! $b_found) { + return -1; + } + elseif ($b_found && ! $a_found) { + return 1; + } + + return strcmp($a, $b); + }); + } + + $str = ''; + foreach ($attributes as $name => $value) { + $value = htmlspecialchars($value, ENT_COMPAT, 'UTF-8', false); + $str .= " $name=\"$value\""; + } + return $str; + } + + public static function normalizePath($path, $strip_drive_letter = false) + { + if (! $path) { + return ''; + } + + if ($strip_drive_letter) { + $path = preg_replace('~^[a-z]\:~i', '', $path); + } + + // Backslashes and repeat slashes to a single forward slash. + $path = rtrim(preg_replace('~[\\\\/]+~', '/', $path), '/'); + + // Removing redundant './'. + $path = str_replace('/./', '/', $path); + if (strpos($path, './') === 0) { + $path = substr($path, 2); + } + + return Util::simplifyPath($path); + } + + public static function simplifyPath($path) + { + // Reduce redundant path segments. e.g 'foo/../bar' => 'bar' + $patt = '~[^/.]+/\.\./~S'; + while (preg_match($patt, $path)) { + $path = preg_replace($patt, '', $path); + } + return $path; + } + + public static function resolveUserPath($path, ?callable $recovery = null, $docRoot = null) + { + // System path. + if ($realpath = realpath($path)) { + $path = $realpath; + } + else { + if (! $docRoot) { + $docRoot = isset(Crush::$process->docRoot) ? Crush::$process->docRoot : Crush::$config->docRoot; + } + + // Absolute path. + if (strpos($path, '/') === 0) { + // If $path is not doc_root based assume it's doc_root relative and prepend doc_root. + if (strpos($path, $docRoot) !== 0) { + $path = $docRoot . $path; + } + } + // Relative path. Try resolving based on the directory of the executing script. + else { + $path = Crush::$config->scriptDir . '/' . $path; + } + + if (! file_exists($path) && $recovery) { + $path = $recovery($path); + } + $path = realpath($path); + } + + return $path ? Util::normalizePath($path) : false; + } + + public static function stripCommentTokens($str) + { + return preg_replace(Regex::$patt->c_token, '', $str); + } + + public static function normalizeWhiteSpace($str) + { + static $find, $replace; + if (! $find) { + $replacements = [ + // Convert all whitespace sequences to a single space. + '~\s+~S' => ' ', + // Trim bracket whitespace where it's safe to do it. + '~([\[(]) | ([\])])| ?([{}]) ?~S' => '${1}${2}${3}', + // Trim whitespace around delimiters and special characters. + '~ ?([;,]) ?~S' => '$1', + ]; + $find = array_keys($replacements); + $replace = array_values($replacements); + } + + return preg_replace($find, $replace, $str); + } + + public static function splitDelimList($str, $options = []) + { + extract($options + [ + 'delim' => ',', + 'regex' => false, + 'allow_empty_strings' => false, + ]); + + $str = trim($str); + + if (! $regex && strpos($str, $delim) === false) { // @phpstan-ignore-line variable.undefined + return ! $allow_empty_strings && ! strlen($str) ? [] : [$str]; // @phpstan-ignore-line variable.undefined + } + + if ($match_count = preg_match_all(Regex::$patt->parens, $str, $matches)) { + $keys = []; + foreach ($matches[0] as $index => &$value) { + $keys[] = "?$index?"; + } + $str = str_replace($matches[0], $keys, $str); + } + + // @phpstan-ignore-next-line variable.undefined + $list = $regex ? preg_split($regex, $str) : explode($delim, $str); + + if ($match_count) { + foreach ($list as &$value) { + $value = str_replace($keys, $matches[0], $value); + } + } + + $list = array_map('trim', $list); + + // @phpstan-ignore-next-line variable.undefined + return ! $allow_empty_strings ? array_filter($list, 'strlen') : $list; + } + + public static function getLinkBetweenPaths($path1, $path2, $directories = true) + { + $path1 = trim(Util::normalizePath($path1, true), '/'); + $path2 = trim(Util::normalizePath($path2, true), '/'); + + $link = ''; + + if ($path1 != $path2) { + + // Split the directory paths into arrays so we can compare segment by segment. + $path1_segs = explode('/', $path1); + $path2_segs = explode('/', $path2); + + // Shift the segments until they are on different branches. + while (isset($path1_segs[0]) && isset($path2_segs[0]) && ($path1_segs[0] === $path2_segs[0])) { + array_shift($path1_segs); + array_shift($path2_segs); + } + + $link = str_repeat('../', count($path1_segs)) . implode('/', $path2_segs); + } + + $link = $link !== '' ? rtrim($link, '/') : ''; + + // Append end slash if getting a link between directories. + if ($link && $directories) { + $link .= '/'; + } + + return $link; + } + + public static function filePutContents($file, $str) + { + if ($stream = fopen($file, 'w')) { + fwrite($stream, $str); + fclose($stream); + + return true; + } + + warning("Could not write file '$file'."); + + return false; + } + + public static function parseIni($path, $sections = false) + { + if (! ($result = @parse_ini_file($path, $sections))) { + notice("Ini file '$path' could not be parsed."); + + return false; + } + return $result; + } + + public static function readConfigFile($path) + { + require_once $path; + return Options::filter(get_defined_vars()); + } + + /* + * Get raw value (useful if testing values that may or may not be a token). + */ + public static function rawValue($value) + { + if ($tokenType = Tokens::test($value)) { + if ($tokenType == 'u') { + $value = Crush::$process->tokens->get($value)->value; + } + elseif ($tokenType == 's') { + $value = Crush::$process->tokens->get($value); + } + } + + return $value; + } + + /* + * Encode integer to Base64 VLQ. + */ + public static function vlqEncode($value) + { + static $VLQ_BASE_SHIFT, $VLQ_BASE, $VLQ_BASE_MASK, $VLQ_CONTINUATION_BIT, $BASE64_MAP; + if (! $VLQ_BASE_SHIFT) { + $VLQ_BASE_SHIFT = 5; + $VLQ_BASE = 1 << $VLQ_BASE_SHIFT; + $VLQ_BASE_MASK = $VLQ_BASE - 1; + $VLQ_CONTINUATION_BIT = $VLQ_BASE; + $BASE64_MAP = str_split('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'); + } + + $vlq = $value < 0 ? ((-$value) << 1) + 1 : ($value << 1) + 0; + + $encoded = ""; + do { + $digit = $vlq & $VLQ_BASE_MASK; + $vlq >>= $VLQ_BASE_SHIFT; + if ($vlq > 0) { + $digit |= $VLQ_CONTINUATION_BIT; + } + $encoded .= $BASE64_MAP[$digit]; + + } while ($vlq > 0); + + return $encoded; + } +} diff --git a/lib/CssCrush/Version.php b/lib/CssCrush/Version.php new file mode 100644 index 0000000..f15d05b --- /dev/null +++ b/lib/CssCrush/Version.php @@ -0,0 +1,116 @@ +\d+) + (?:\.(?\d+))? + (?:\.(?\d+))? + (?:-(?.+))? + $~ix', + $version_string, + $version); + + if ($version) { + $this->major = (int) $version['major']; + $this->minor = isset($version['minor']) ? (int) $version['minor'] : 0; + $this->patch = isset($version['patch']) ? (int) $version['patch'] : 0; + $this->extra = isset($version['extra']) ? $version['extra'] : null; + } + } + + public function __toString() + { + $out = (string) $this->major; + + if (isset($this->minor)) { + $out .= ".$this->minor"; + } + if (isset($this->patch)) { + $out .= ".$this->patch"; + } + if (isset($this->extra)) { + $out .= "-$this->extra"; + } + + return "v$out"; + } + + public function compare($version_string) + { + $LESS = -1; + $MORE = 1; + $EQUAL = 0; + + $test = new Version($version_string); + + foreach (['major', 'minor', 'patch'] as $level) { + + if ($this->{$level} < $test->{$level}) { + + return $LESS; + } + elseif ($this->{$level} > $test->{$level}) { + + return $MORE; + } + } + + return $EQUAL; + } + + public static function detect() { + return self::gitDescribe() ?: self::packageDescribe(); + } + + public static function gitDescribe() + { + static $attempted, $version; + if (! $attempted && file_exists(Crush::$dir . '/.git')) { + $attempted = true; + $command = 'cd ' . escapeshellarg(Crush::$dir) . ' && git describe --tag --long'; + @exec($command, $lines); + if ($lines) { + $version = new Version(trim($lines[0])); + if (is_null($version->major)) { + $version = null; + } + } + } + + return $version; + } + + public static function packageDescribe() + { + static $attempted, $version; + if (! $attempted && file_exists(Crush::$dir . '/package.json')) { + $attempted = true; + $package = json_decode(file_get_contents(Crush::$dir . '/package.json')); + if ($package->version) { + $version = new Version($package->version); + if (is_null($version->major)) { + $version = null; + } + } + } + + return $version; + } +} diff --git a/lib/Function.php b/lib/Function.php deleted file mode 100644 index bbfc285..0000000 --- a/lib/Function.php +++ /dev/null @@ -1,365 +0,0 @@ -list ); - } - - protected static function colorAdjust ( $color, array $adjustments ) { - - $fn_matched = preg_match( '!^(#|rgba?|hsla?)!', $color, $m ); - $keywords = csscrush_color::getKeywords(); - - // Support for Hex, RGB, RGBa and keywords - // HSL and HSLa are passed over - if ( $fn_matched or array_key_exists( $color, $keywords ) ) { - - $alpha = 1; - $rgb = null; - - // Get an RGB array from the color argument - if ( $fn_matched ) { - switch ( $m[1] ) { - case '#': - $rgb = csscrush_color::hexToRgb( $color ); - break; - - case 'rgb': - case 'rgba': - case 'hsl': - case 'hsla': - $function = $m[1]; - $alpha_channel = 4 === strlen( $function ) ? true : false; - $vals = substr( $color, strlen( $function ) + 1 ); // Trim function name and start paren - $vals = substr( $vals, 0, strlen( $vals ) - 1 ); // Trim end paren - $vals = array_map( 'trim', explode( ',', $vals ) ); // Explode to array of arguments - if ( $alpha_channel ) { - $alpha = array_pop( $vals ); - } - if ( 0 === strpos( $function, 'rgb' ) ) { - $rgb = csscrush_color::normalizeCssRgb( $vals ); - } - else { - $rgb = csscrush_color::cssHslToRgb( $vals ); - } - break; - } - } - else { - $rgb = $keywords[ $color ]; - } - - $hsl = csscrush_color::rgbToHsl( $rgb ); - - // Normalize adjustment parameters to floating point numbers - // then calculate the new HSL value - $index = 0; - foreach ( $adjustments as $val ) { - // Normalize argument - $_val = $val ? trim( str_replace( '%', '', $val ) ) : 0; - - // Reduce value to float - $_val /= 100; - - // Adjust alpha component if necessary - if ( 3 === $index ) { - if ( 0 != $val ) { - $alpha = max( 0, min( 1, $alpha + $_val ) ); - } - } - // Adjust HSL component value if necessary - else { - if ( 0 != $val ) { - $hsl[ $index ] = max( 0, min( 1, $hsl[ $index ] + $_val ) ); - } - } - $index++; - } - - // Finally convert new HSL value to RGB - $rgb = csscrush_color::hslToRgb( $hsl ); - - // Return as hex if there is no modified alpha channel - // Otherwise return RGBA string - if ( 1 === $alpha ) { - return csscrush_color::rgbToHex( $rgb ); - } - $rgb[] = $alpha; - return 'rgba(' . implode( ',', $rgb ) . ')'; - } - else { - return $color; - } - } - - - ############ - - public static function css_fn__math ( $input ) { - // Whitelist allowed characters - $input = preg_replace( '![^\.0-9\*\/\+\-\(\)]!', '', $input ); - - $result = @eval( "return $input;" ); - - return $result === false ? 0 : round( $result, 10 ); - } - - public static function css_fn__percent ( $input ) { - - $args = self::parseMathArgs( $input ); - - // Use precision argument if it exists, use default otherwise - $precision = isset( $args[2] ) ? $args[2] : 5; - - // Output zero on failure - $result = 0; - - // Need to check arguments or we may see divide by zero errors - if ( count( $args ) > 1 and !empty( $args[0] ) and !empty( $args[1] ) ) { - - // Use bcmath if it's available for higher precision - - // Arbitary high precision division - if ( function_exists( 'bcdiv' ) ) { - $div = bcdiv( $args[0], $args[1], 25 ); - } - else { - $div = $args[0] / $args[1]; - } - - // Set precision percentage value - if ( function_exists( 'bcmul' ) ) { - $result = bcmul( (string) $div, '100', $precision ); - } - else { - $result = round( $div * 100, $precision ); - } - - // Trim unnecessary zeros and decimals - $result = trim( (string) $result, '0' ); - $result = rtrim( $result, '.' ); - } - - return $result . '%'; - } - - // Percent function alias - public static function css_fn__pc ( $input ) { - return self::css_fn_percent( $input ); - } - - public static function css_fn__data_uri ( $input ) { - - // Normalize, since argument might be a string token - if ( strpos( $input, '___s' ) === 0 ) { - $string_labels = array_keys( csscrush::$storage->tokens->strings ); - $string_values = array_values( csscrush::$storage->tokens->strings ); - $input = trim( str_replace( $string_labels, $string_values, $input ), '\'"`' ); - } - - // Default return value - $result = "url(http://github.com/$input)"; - - // No attempt to process absolute urls - if ( preg_match( csscrush::$regex->absoluteUrl, $input ) ) { - return $result; - } - - // Get system file path - if ( strpos( $input, '/' ) === 0 ) { - $file = csscrush::$config->docRoot . $input; - } - else { - $baseDir = csscrush::$config->baseDir; - $file = "$baseDir/$input"; - } - - // File not found - if ( !file_exists( $file ) ) { - return $result; - } - - $file_ext = pathinfo( $file, PATHINFO_EXTENSION ); - - // Only allow certain extensions - $allowed_file_extensions = array( - 'woff' => 'font/woff;charset=utf-8', - 'ttf' => 'font/truetype;charset=utf-8', - 'svg' => 'image/svg+xml', - 'svgz' => 'image/svg+xml', - 'gif' => 'image/gif', - 'jpeg' => 'image/jpg', - 'jpg' => 'image/jpg', - 'png' => 'image/png', - ); - if ( !array_key_exists( $file_ext, $allowed_file_extensions ) ) { - return $result; - } - - $mime_type = $allowed_file_extensions[ $file_ext ]; - $base64 = base64_encode( file_get_contents( $file ) ); - $data_uri = "data:{$mime_type};base64,$base64"; - - return "url(http://github.com/%22$data_uri/")"; - } - - public static function css_fn__h_adjust ( $input ) { - @list( $color, $h ) = self::parseArgs( $input, true ); - return self::colorAdjust( $color, array( $h, 0, 0, 0 ) ); - } - - public static function css_fn__s_adjust ( $input ) { - @list( $color, $s ) = self::parseArgs( $input, true ); - return self::colorAdjust( $color, array( 0, $s, 0, 0 ) ); - } - - public static function css_fn__l_adjust ( $input ) { - @list( $color, $l ) = self::parseArgs( $input, true ); - return self::colorAdjust( $color, array( 0, 0, $l, 0 ) ); - } - - public static function css_fn__a_adjust ( $input ) { - @list( $color, $a ) = self::parseArgs( $input, true ); - return self::colorAdjust( $color, array( 0, 0, 0, $a ) ); - } - -} - - - diff --git a/lib/Hook.php b/lib/Hook.php deleted file mode 100644 index 36a1956..0000000 --- a/lib/Hook.php +++ /dev/null @@ -1,28 +0,0 @@ -data[ csscrush::$compileName ] = $data; - - // Need to store the current path so we can check we're using the right config path later - $config->data[ 'originPath' ] = $config->path; - - // Save config changes - file_put_contents( $config->path, serialize( $config->data ) ); - } - - - public static function hostfile ( $hostfile ) { - - $config = csscrush::$config; - $options = csscrush::$options; - $regex = csscrush::$regex; - - // Keep track of all import file info for later logging - $mtimes = array(); - $filenames = array(); - - // Determine input; string or file - // Extract the comments then strings - $stream = isset( $hostfile->string ) ? $hostfile->string : file_get_contents( $hostfile->path ); - - // If there's a prepend file, prepend it - if ( $prependFile = csscrush_util::find( 'Prepend-local.css', 'Prepend.css' ) ) { - $stream = file_get_contents( $prependFile ) . $stream; - } - - $stream = csscrush::extractComments( $stream ); - $stream = csscrush::extractStrings( $stream ); - - // This may be set non-zero during the script if an absolute URL is encountered - $searchOffset = 0; - - // Recurses until the nesting heirarchy is flattened and all files are combined - while ( preg_match( $regex->import, $stream, $match, PREG_OFFSET_CAPTURE, $searchOffset ) ) { - - $fullMatch = $match[0][0]; // Full match - $matchStart = $match[0][1]; // Full match offset - $matchEnd = $matchStart + strlen( $fullMatch ); - $preStatement = substr( $stream, 0, $matchStart ); - $postStatement = substr( $stream, $matchEnd ); - - // If just stripping the import statements - if ( isset( $hostfile->importIgnore ) ) { - $stream = $preStatement . $postStatement; - continue; - } - - // The media context (if specified) at position 3 in the match - $mediaContext = trim( $match[3][0] ); - - // The url may be at position 1 or 2 in the match depending on the syntax used - $url = trim( $match[1][0] ); - if ( ! $url ) { - $url = trim( $match[2][0] ); - } - - // Url may be a string token - if ( preg_match( $regex->token->string, $url ) ) { - $import_url_token = new csscrush_string( $url ); - $url = $import_url_token->value; - } - - // csscrush::log( $match ); - - // Pass over absolute urls - // Move the search pointer forward - if ( preg_match( $regex->absoluteUrl, $url ) ) { - $searchOffset = $matchEnd; - continue; - } - - // Create import object - $import = new stdClass; - $import->url = $url; - $import->mediaContext = $mediaContext; - $import->hostDir = $hostfile->dir; - - // Check to see if the url is root relative - // Flatten import path for convenience - if ( strpos( $import->url, '/' ) === 0 ) { - $import->path = realpath( $config->docRoot . $import->url ); - } - else { - $import->path = realpath( "$hostfile->dir/$import->url" ); - } - - $import->content = @file_get_contents( $import->path ); - - // Failed to open import, just continue with the import line removed - if ( ! $import->content ) { - csscrush::log( "Import file '$import->url' not found" ); - $stream = $preStatement . $postStatement; - continue; - - } - // Import file opened successfully so we process it: - // We need to resolve import statement urls in all imported files since - // they will be brought inline with the hostfile - else { - - // Start with extracting comments in the import - $import->content = csscrush::extractComments( $import->content ); - - $import->dir = dirname( $import->url ); - - // Store import file info for cache validation - $mtimes[] = filemtime( $import->path ); - $filenames[] = $import->url; - - // Alter all the url strings to be paths relative to the hostfile: - // Match all @import statements in the import content - // Store the replacements we might find - $matchCount = preg_match_all( $regex->import, $import->content, $matchAll, - PREG_OFFSET_CAPTURE ); - $replacements = array(); - for ( $index = 0; $index < $matchCount; $index++ ) { - - $fullMatch = $matchAll[0][ $index ][0]; - $urlMatch = $matchAll[1][ $index ][0]; - - $search = $urlMatch; - $replace = "$import->dir/$urlMatch"; - - // Try to resolve absolute paths - // On failure strip the @import statement - if ( strpos( $urlMatch, '/' ) === 0 ) { - $replace = self::resolveAbsolutePath( $urlMatch ); - if ( ! $replace ) { - $search = $fullMatch; - $replace = ''; - } - } - - // Trim the statement and set the resolved path - $statement = trim( str_replace( $search, $replace, $fullMatch ) ); - - // Normalise import statement to be without url() syntax: - // This is so relative urls can easily be targeted later - $statement = self::normalizeImportStatement( $statement ); - - $replacements[ $fullMatch ] = $statement; - } - - // If we've stored any altered @import strings then we need to apply them - if ( count( $replacements ) ) { - $import->content = str_replace( - array_keys( $replacements ), - array_values( $replacements ), - $import->content ); - } - - // Now @import urls have been adjusted extract strings - $import->content = csscrush::extractStrings( $import->content ); - - // Optionally rewrite relative url and custom function data-uri references - if ( $options[ 'rewrite_import_urls' ] ) { - $import->content = self::rewriteImportRelativeUrls( $import ); - } - - // Add media context if it exists - if ( $import->mediaContext ) { - $import->content = "@media $import->mediaContext {" . $import->content . '}'; - } - - $stream = $preStatement . $import->content . $postStatement; - } - - } // End while - - // Save only if the hostfile object is associated with a real file - if ( $hostfile->path ) { - self::save( array( - 'imports' => $filenames, - 'datem_sum' => array_sum( $mtimes ) + $hostfile->mtime, - 'options' => $options, - )); - } - - return $stream; - } - - - protected static function normalizeImportStatement ( $statement ) { - - $url_import_patt = '!^@import\s+url\(\s*!'; - if ( preg_match( $url_import_patt, $statement ) ) { - // Example matches: - // @import url(http://github.com/%22some_path_with_(parens).css") screen and ( max-width: 500px ); - // @import url(http://github.com/some_path.css); - - // Trim the first part - $statement = preg_replace( $url_import_patt, '', $statement ); - - // 'some_path_with_(parens).css') screen and ( max-width: 500px ); - if ( preg_match( '!^([\'"])!', $statement, $m ) ) { - $statement = preg_replace( '!' . $m[1] . '\s*\)!', $m[1], $statement, 1 ); - } - // some_path.css) screen and ( max-width: 500px ); - else { - $statement = '"' . preg_replace( '!\s*\)!', '"', $statement, 1 ); - } - // Pull back together - $statement = '@import ' . $statement; - } - return $statement; - } - - - protected static function resolveAbsolutePath ( $url ) { - - $config = csscrush::$config; - - if ( ! file_exists ( $config->docRoot . $url ) ) { - return false; - } - // Move upwards '..' by the number of slashes in baseURL to get a relative path - $url = str_repeat( '../', substr_count( $config->baseURL, '/' ) ) . substr( $url, 1 ); - - return $url; - } - - - protected static function rewriteImportRelativeUrls ( $import ) { - - $stream = $import->content; - - // We're comparing file system position so we'll - $hostDir = csscrush_util::normalizeSystemPath( $import->hostDir, true ); - $importDir = csscrush_util::normalizeSystemPath( dirname( $import->path ), true ); - - csscrush::$storage->tmp->relativeUrlPrefix = ''; - $url_prefix = ''; - - if ( $importDir === $hostDir ) { - // Do nothing if files are in the same directory - return $stream; - - } - elseif ( strpos( $importDir, $hostDir ) === false ) { - // Import directory is higher than the host directory - - // Split the directory paths into arrays so we can compare segment by segment - $host_segs = preg_split( '!/+!', $hostDir, null, PREG_SPLIT_NO_EMPTY ); - $import_segs = preg_split( '!/+!', $importDir, null, PREG_SPLIT_NO_EMPTY ); - - // Shift the segments until they are on different branches - while ( @( $host_segs[0] == $import_segs[0] ) ) { - array_shift( $host_segs ); - array_shift( $import_segs ); - // csscrush::log( array( $host_segs, $import_segs ) ); - } - - // Count the remaining $host_segs to get the offset - $level_diff = count( $host_segs ); - - $url_prefix = str_repeat( '../', $level_diff ) . implode( '/', $import_segs ); - - } - else { - // Import directory is lower than host directory - - // easy, url_prefix is the difference - $url_prefix = substr( $importDir, strlen( $hostDir ) + 1 ); - } - - if ( empty( $url_prefix ) ) { - return $stream; - } - - // Add the directory seperator ending (if needed) - if ( $url_prefix[ strlen( $url_prefix ) - 1 ] !== '/' ) { - $url_prefix .= '/'; - } - - csscrush::log( 'relative_url_prefix: ' . $url_prefix ); - - // Search for all relative url and data-uri references in the content - // and prepend $relative_url_prefix - - // Make $url_prefix accessible in callback scope - csscrush::$storage->tmp->relativeUrlPrefix = $url_prefix; - - $url_function_patt = '! - ([^a-z-]) # the preceeding character - (data-uri|url) # the function name - \(\s*([^\)]+)\s*\) # the url - !xi'; - $stream = preg_replace_callback( $url_function_patt, - array( 'self', 'cb_rewriteImportRelativeUrl' ), $stream ); - - return $stream; - } - - - protected static function cb_rewriteImportRelativeUrl ( $match ) { - - $regex = csscrush::$regex; - $storage = csscrush::$storage; - - // The relative url prefix - $relative_url_prefix = $storage->tmp->relativeUrlPrefix; - - list( $fullMatch, $before, $function, $url ) = $match; - $url = trim( $url ); - - // If the url is a string token we'll need to restore it as a string token later - if ( $url_is_token = preg_match( $regex->token->string, $url ) ) { - - $url_token = new csscrush_string( $url ); - $url = $url_token->value; - } - - // No rewrite if: - // $url begins with a variable, e.g '$(' - // $url path is absolute or begins with slash - // $url is an empty string - if ( - empty( $url ) or - strpos( $url, '/' ) === 0 or - strpos( $url, '$(' ) === 0 or - preg_match( $regex->absoluteUrl, $url ) - ) { - // Token or not, it's ok to return the full match if $url is a root relative or absolute ref - return $fullMatch; - } - - // Prepend the relative url prefix - $url = $relative_url_prefix . $url; - - // Restore quotes if $url was a string token - if ( $url_is_token ) { - $url = $url_token->quoteMark . $url . $url_token->quoteMark; - } - - // Reconstruct the match and return - return "$before$function($url)"; - } - -} diff --git a/lib/Rule.php b/lib/Rule.php deleted file mode 100644 index 7e98948..0000000 --- a/lib/Rule.php +++ /dev/null @@ -1,375 +0,0 @@ -parens += $selectors_match->matches; - - // Remove and store comments that sit above the first selector - // remove all comments between the other selectors - preg_match_all( $regex->token->comment, $selectors_match->list[0], $m ); - $this->comments = $m[0]; - foreach ( $selectors_match->list as &$selector ) { - $selector = preg_replace( $regex->token->comment, '', $selector ); - $selector = trim( $selector ); - } - $this->selectors = $selectors_match->list; - } - - // Apply any custom functions - $declarations_string = csscrush_function::parseAndExecuteValue( $declarations_string ); - - // Parse the declarations chunk - // Need to split safely as there are semi-colons in data-uris - $declarations_match = csscrush_util::splitDelimList( $declarations_string, ';' ); - $this->parens += $declarations_match->matches; - - // Parse declarations in to property/value pairs - foreach ( $declarations_match->list as $declaration ) { - // Strip comments around the property - $declaration = preg_replace( $regex->token->comment, '', $declaration ); - - // Store the property - $colonPos = strpos( $declaration, ':' ); - if ( $colonPos === false ) { - // If there is no colon it's malformed - continue; - } - - // The property name - $prop = trim( substr( $declaration, 0, $colonPos ) ); - - // Test for escape tilde - if ( $skip = strpos( $prop, '~' ) === 0 ) { - $prop = substr( $prop, 1 ); - } - // Store the property name - $this->addProperty( $prop ); - - // Store the property family - // Store the vendor id, if one is present - if ( preg_match( $regex->vendorPrefix, $prop, $vendor ) ) { - $family = $vendor[2]; - $vendor = $vendor[1]; - } - else { - $vendor = null; - $family = $prop; - } - - // Extract the value part of the declaration - $value = substr( $declaration, $colonPos + 1 ); - $value = $value !== false ? trim( $value ) : $value; - if ( $value === false or $value === '' ) { - // We'll ignore declarations with empty values - continue; - } - - // Create an index of all functions in the current declaration - if ( preg_match_all( $regex->function->match, $value, $functions ) > 0 ) { - // csscrush::log( $functions ); - $out = array(); - foreach ( $functions[2] as $index => $fn_name ) { - $out[] = $fn_name; - } - $functions = array_unique( $out ); - } - else { - $functions = array(); - } - - // Store the declaration - $_declaration = (object) array( - 'property' => $prop, - 'family' => $family, - 'vendor' => $vendor, - 'functions' => $functions, - 'value' => $value, - 'skip' => $skip, - ); - $this->declarations[] = $_declaration; - } - } - - public function addPropertyAliases () { - - $regex = csscrush::$regex; - $aliasedProperties =& csscrush::$aliases[ 'properties' ]; - - // First test for the existence of any aliased properties - $intersect = array_intersect( array_keys( $aliasedProperties ), array_keys( $this->properties ) ); - if ( empty( $intersect ) ) { - return; - } - - // Shim in aliased properties - $new_set = array(); - foreach ( $this->declarations as $declaration ) { - $prop = $declaration->property; - if ( - !$declaration->skip and - isset( $aliasedProperties[ $prop ] ) - ) { - // There are aliases for the current property - foreach ( $aliasedProperties[ $prop ] as $prop_alias ) { - if ( $this->propertyCount( $prop_alias ) ) { - continue; - } - // If the aliased property hasn't been set manually, we create it - $copy = clone $declaration; - $copy->family = $copy->property; - $copy->property = $prop_alias; - // Remembering to set the vendor property - $copy->vendor = null; - // Increment the property count - $this->addProperty( $prop_alias ); - if ( preg_match( $regex->vendorPrefix, $prop_alias, $vendor ) ) { - $copy->vendor = $vendor[1]; - } - $new_set[] = $copy; - } - } - // Un-aliased property or a property alias that has been manually set - $new_set[] = $declaration; - } - // Re-assign - $this->declarations = $new_set; - } - - public function addFunctionAliases () { - - $function_aliases =& csscrush::$aliases[ 'functions' ]; - $aliased_functions = array_keys( $function_aliases ); - - if ( empty( $aliased_functions ) ) { - return; - } - - $new_set = array(); - - // Keep track of the function aliases we apply and to which property 'family' - // they belong, so we can avoid un-unecessary duplications - $used_fn_aliases = array(); - - // Shim in aliased functions - foreach ( $this->declarations as $declaration ) { - - // No functions, skip - if ( - $declaration->skip or - empty( $declaration->functions ) - ) { - $new_set[] = $declaration; - continue; - } - // Get list of functions used in declaration that are alias-able, if none skip - $intersect = array_intersect( $declaration->functions, $aliased_functions ); - if ( empty( $intersect ) ) { - $new_set[] = $declaration; - continue; - } - // csscrush::log($intersect); - // Loop the aliasable functions - foreach ( $intersect as $fn_name ) { - - if ( $declaration->vendor ) { - // If the property is vendor prefixed we use the vendor prefixed version - // of the function if it exists. - // Else we just skip and use the unprefixed version - $fn_search = "-{$declaration->vendor}-$fn_name"; - if ( in_array( $fn_search, $function_aliases[ $fn_name ] ) ) { - $declaration->value = preg_replace( - '!(^| |,)' . $fn_name . '!', - '${1}' . $fn_search, - $declaration->value - ); - $used_fn_aliases[ $declaration->family ][] = $fn_search; - } - } - else { - - // Duplicate the rule for each alias - foreach ( $function_aliases[ $fn_name ] as $fn_alias ) { - - if ( - isset( $used_fn_aliases[ $declaration->family ] ) and - in_array( $fn_alias, $used_fn_aliases[ $declaration->family ] ) - ) { - // If the function alias has already been applied in a vendor property - // for the same declaration property assume all is good - continue; - } - $copy = clone $declaration; - $copy->value = preg_replace( - '!(^| |,)' . $fn_name . '!', - '${1}' . $fn_alias, - $copy->value - ); - $new_set[] = $copy; - // Increment the property count - $this->addProperty( $copy->property ); - } - } - } - $new_set[] = $declaration; - } - - // Re-assign - $this->declarations = $new_set; - } - - public function addValueAliases () { - - $aliasedValues =& csscrush::$aliases[ 'values' ]; - - // First test for the existence of any aliased properties - $intersect = array_intersect( array_keys( $aliasedValues ), array_keys( $this->properties ) ); - - if ( empty( $intersect ) ) { - return; - } - - $new_set = array(); - foreach ( $this->declarations as $declaration ) { - if ( !$declaration->skip ) { - foreach ( $aliasedValues as $value_prop => $value_aliases ) { - if ( $this->propertyCount( $value_prop ) < 1 ) { - continue; - } - foreach ( $value_aliases as $value => $aliases ) { - if ( $declaration->value === $value ) { - foreach ( $aliases as $alias ) { - $copy = clone $declaration; - $copy->value = $alias; - $new_set[] = $copy; - } - } - } - } - } - $new_set[] = $declaration; - } - // Re-assign - $this->declarations = $new_set; - } - - public function expandSelectors () { - - $new_set = array(); - $reg_comma = '!\s*,\s*!'; - - foreach ( $this->selectors as $selector ) { - $pos = strpos( $selector, ':any___' ); - if ( $pos !== false ) { - // Contains an :any statement so we expand - $chain = array( '' ); - do { - if ( $pos === 0 ) { - preg_match( '!:any(___p\d+___)!', $selector, $m ); - - // Parse the arguments - $expression = trim( $this->parens[ $m[1] ], '()' ); - $parts = preg_split( $reg_comma, $expression, null, PREG_SPLIT_NO_EMPTY ); - - $tmp = array(); - foreach ( $chain as $rowCopy ) { - foreach ( $parts as $part ) { - $tmp[] = $rowCopy . $part; - } - } - $chain = $tmp; - $selector = substr( $selector, strlen( $m[0] ) ); - } - else { - foreach ( $chain as &$row ) { - $row .= substr( $selector, 0, $pos ); - } - $selector = substr( $selector, $pos ); - } - } while ( ( $pos = strpos( $selector, ':any___' ) ) !== false ); - - // Finish off - foreach ( $chain as &$row ) { - $new_set[] = $row . $selector; - } - } - else { - // Nothing special - $new_set[] = $selector; - } - } - $this->selectors = $new_set; - } - - - ############ - # IteratorAggregate - - public function getIterator () { - return new ArrayIterator( $this->declarations ); - } - - - ############ - # Rule API - - public function propertyCount ( $prop ) { - if ( array_key_exists( $prop, $this->properties ) ) { - return $this->properties[ $prop ]; - } - return 0; - } - - // Add property to the rule index keeping track of the count - public function addProperty ( $prop ) { - if ( isset( $this->properties[ $prop ] ) ) { - $this->properties[ $prop ]++; - } - else { - $this->properties[ $prop ] = 1; - } - } - - public function createDeclaration ( $property, $value, $options = array() ) { - // Test for escape tilde - if ( $skip = strpos( $property, '~' ) === 0 ) { - $property = substr( $property, 1 ); - } - $_declaration = array( - 'property' => $property, - 'family' => null, - 'vendor' => null, - 'value' => $value, - 'skip' => $skip, - ); - $this->addProperty( $property ); - return (object) array_merge( $_declaration, $options ); - } - - // Get a declaration value without paren tokens - public function getDeclarationValue ( $declaration ) { - $paren_keys = array_keys( $this->parens ); - $paren_values = array_values( $this->parens ); - return str_replace( $paren_keys, $paren_values, $declaration->value ); - } - -} \ No newline at end of file diff --git a/lib/Util.php b/lib/Util.php deleted file mode 100644 index df2be4d..0000000 --- a/lib/Util.php +++ /dev/null @@ -1,232 +0,0 @@ - $value ) { - $value = htmlspecialchars( $value, ENT_COMPAT, 'UTF-8', false ); - $attr_string .= " $name=\"$value\""; - } - return $attr_string; - } - - - public static function normalizeSystemPath ( $path, $stripMsDos = false ) { - $path = rtrim( str_replace( '\\', '/', $path ), '/' ); - - if ( $stripMsDos ) { - $path = preg_replace( '!^[a-z]\:!i', '', $path ); - } - return $path; - } - - - public static function find () { - - foreach ( func_get_args() as $file ) { - $file_path = csscrush::$location . '/' . $file; - if ( file_exists( $file_path ) ) { - return $file_path; - } - } - return false; - } - - - public static function normalizeWhiteSpace ( $str ) { - $replacements = array( - '!\s+!' => ' ', - '!(\[)\s*|\s*(\])|(\()\s*|\s*(\))!' => '${1}${2}${3}${4}', // Trim internal bracket WS - '!\s*(;|,|\/|\!)\s*!' => '$1', // Trim WS around delimiters and special characters - ); - return preg_replace( - array_keys( $replacements ), array_values( $replacements ), $str ); - } - - - public static function splitDelimList ( $str, $delim, $fold_in = false, $allow_empty = false ) { - - $match_obj = self::matchAllBrackets( $str ); - - // If the delimiter is one character do a simple split - // Otherwise do a regex split - if ( 1 === strlen( $delim ) ) { - $match_obj->list = explode( $delim, $match_obj->string ); - } - else { - $match_obj->list = preg_split( '!' . $delim . '!', $match_obj->string ); - } - - if ( false === $allow_empty ) { - $match_obj->list = array_filter( $match_obj->list ); - } - if ( $fold_in ) { - $match_keys = array_keys( $match_obj->matches ); - $match_values = array_values( $match_obj->matches ); - foreach ( $match_obj->list as &$item ) { - $item = str_replace( $match_keys, $match_values, $item ); - } - } - return $match_obj; - } - - - public static function matchBrackets ( $str, $brackets = array( '(', ')' ), $search_pos = 0 ) { - - list( $opener, $closer ) = $brackets; - $openings = array(); - $closings = array(); - $brake = 50; // Set a limit in the case of errors - - $match = new stdClass; - - $start_index = strpos( $str, $opener, $search_pos ); - $close_index = strpos( $str, $closer, $search_pos ); - - if ( $start_index === false ) { - return false; - } - if ( substr_count( $str, $opener ) !== substr_count( $str, $closer ) ) { - $sample = substr( $str, 0, 15 ); - trigger_error( __METHOD__ . ": Unmatched token near '$sample'.\n", E_USER_WARNING ); - return false; - } - - while ( - ( $start_index !== false or $close_index !== false ) and $brake-- - ) { - if ( $start_index !== false and $close_index !== false ) { - $search_pos = min( $start_index, $close_index ); - if ( $start_index < $close_index ) { - $openings[] = $start_index; - } - else { - $closings[] = $close_index; - } - } - elseif ( $start_index !== false ) { - $search_pos = $start_index; - $openings[] = $start_index; - } - else { - $search_pos = $close_index; - $closings[] = $close_index; - } - $search_pos += 1; // Advance - - if ( count( $closings ) === count( $openings ) ) { - $match->openings = $openings; - $match->closings = $closings; - $match->start = $openings[0]; - $match->end = $closings[ count( $closings ) - 1 ] + 1; - return $match; - } - $start_index = strpos( $str, $opener, $search_pos ); - $close_index = strpos( $str, $closer, $search_pos ); - } - - trigger_error( __METHOD__ . ": Reached brake limit of '$brake'. Exiting.\n", E_USER_WARNING ); - return false; - } - - - public static function matchAllBrackets ( $str, $pair = '()', $offset = 0 ) { - - $match_obj = new stdClass; - $match_obj->string = $str; - $match_obj->raw = $str; - $match_obj->matches = array(); - - list( $opener, $closer ) = str_split( $pair, 1 ); - - // Return early if there's no match - if ( false === ( $first_offset = strpos( $str, $opener, $offset ) ) ) { - return $match_obj; - } - - // Step through the string one character at a time storing offsets - $paren_score = -1; - $inside_paren = false; - $match_start = 0; - $offsets = array(); - - for ( $index = $first_offset; $index < strlen( $str ); $index++ ) { - $char = $str[ $index ]; - - if ( $opener === $char ) { - if ( !$inside_paren ) { - $paren_score = 1; - $match_start = $index; - } - else { - $paren_score++; - } - $inside_paren = true; - } - elseif ( $closer === $char ) { - $paren_score--; - } - - if ( 0 === $paren_score ) { - $inside_paren = false; - $paren_score = -1; - $offsets[] = array( $match_start, $index + 1 ); - } - } - - // Step backwards through the matches - while ( $offset = array_pop( $offsets ) ) { - list( $start, $finish ) = $offset; - - $before = substr( $str, 0, $start ); - $content = substr( $str, $start, $finish - $start ); - $after = substr( $str, $finish ); - - $label = csscrush::createTokenLabel( 'p' ); - $str = $before . $label . $after; - $match_obj->matches[ $label ] = $content; - - // Parens will be folded in later - csscrush::$storage->tokens->parens[ $label ] = $content; - } - - $match_obj->string = $str; - - return $match_obj; - } - - -} - - -class csscrush_string { - - public $token; - - public $value; - - public $raw; - - public $quoteMark; - - public function __construct ( $token ) { - - $this->token = trim( $token ); - $this->raw = csscrush::$storage->tokens->strings[ $token ]; - $this->value = trim( $this->raw, '\'"' ); - $this->quoteMark = $this->raw[0]; - } - - public function update ( $newValue ) { - csscrush::$storage->tokens->strings = $newValue; - } -} - - diff --git a/lib/functions.php b/lib/functions.php new file mode 100644 index 0000000..c8abc02 --- /dev/null +++ b/lib/functions.php @@ -0,0 +1,167 @@ + 'file', 'data' => $file]); + } + catch (\Exception $e) { + CssCrush\warning($e->getMessage()); + + return ''; + } + + return new CssCrush\File(Crush::$process); +} + + +/** + * Process CSS file and return an HTML link tag with populated href. + * + * @see docs/api/functions.md + */ +function csscrush_tag($file, $options = [], $tag_attributes = []) { + + $file = csscrush_file($file, $options); + if ($file && $file->url) { + $tag_attributes['href'] = $file->url; + $tag_attributes += [ + 'rel' => 'stylesheet', + 'media' => 'all', + ]; + $attrs = CssCrush\Util::htmlAttributes($tag_attributes, ['rel', 'href', 'media']); + + return "\n"; + } +} + + +/** + * Process CSS file and return CSS as text wrapped in html style tags. + * + * @see docs/api/functions.md + */ +function csscrush_inline($file, $options = [], $tag_attributes = []) { + + if (! is_array($options)) { + $options = []; + } + if (! isset($options['boilerplate'])) { + $options['boilerplate'] = false; + } + + $file = csscrush_file($file, $options); + if ($file && $file->path) { + $tagOpen = ''; + $tagClose = ''; + if (is_array($tag_attributes)) { + $attrs = CssCrush\Util::htmlAttributes($tag_attributes); + $tagOpen = ""; + $tagClose = ''; + } + return $tagOpen . file_get_contents($file->path) . $tagClose . "\n"; + } +} + + +/** + * Compile a raw string of CSS string and return it. + * + * @see docs/api/functions.md + */ +function csscrush_string($string, $options = []) { + + if (! isset($options['boilerplate'])) { + $options['boilerplate'] = false; + } + + Crush::$process = new CssCrush\Process($options, ['type' => 'filter', 'data' => $string]); + + return Crush::$process->compile()->__toString(); +} + + +/** + * Set default options and config settings. + * + * @see docs/api/functions.md + */ +function csscrush_set($object_name, $modifier) { + + if (in_array($object_name, ['options', 'config'])) { + + $pointer = $object_name === 'options' ? Crush::$config->options : Crush::$config; + + if (is_callable($modifier)) { + $modifier($pointer); + } + elseif (is_array($modifier)) { + foreach ($modifier as $key => $value) { + $pointer->{$key} = $value; + } + } + } +} + + +/** + * Get default options and config settings. + * + * @see docs/api/functions.md + */ +function csscrush_get($object_name, $property = null) { + + if (in_array($object_name, ['options', 'config'])) { + + $pointer = $object_name === 'options' ? Crush::$config->options : Crush::$config; + + if (! isset($property)) { + return $pointer; + } + else { + return isset($pointer->{$property}) ? $pointer->{$property} : null; + } + } + return null; +} + + +/** + * Add plugin. + * + * @see docs/api/functions.md + */ +function csscrush_plugin($name, callable $callback) { + + Crush::plugin($name, $callback); +} + + +/** + * Get stats from most recent compile. + * + * @see docs/api/functions.md + */ +function csscrush_stat() { + + $process = Crush::$process; + $stats = $process->stat; + + // Get logged errors as late as possible. + $stats['errors'] = $process->errors; + $stats['warnings'] = $process->warnings; + $stats += ['compile_time' => 0]; + + return $stats; +} diff --git a/misc/color-keywords.ini b/misc/color-keywords.ini index dcbed9b..e8ec1e0 100644 --- a/misc/color-keywords.ini +++ b/misc/color-keywords.ini @@ -120,6 +120,7 @@ pink = "255,192,203" plum = "221,160,221" powderblue = "176,224,230" purple = "128,0,128" +rebeccapurple = "102,51,153" red = "255,0,0" rosybrown = "188,143,143" royalblue = "65,105,225" diff --git a/misc/formatters.php b/misc/formatters.php new file mode 100644 index 0000000..2117837 --- /dev/null +++ b/misc/formatters.php @@ -0,0 +1,48 @@ +formatters = [ + 'single-line' => 'CssCrush\fmtr_single', + 'padded' => 'CssCrush\fmtr_padded', + 'block' => 'CssCrush\fmtr_block', +]; + +function fmtr_single($rule) { + + $EOL = Crush::$process->newline; + + $selectors = $rule->selectors->join(', '); + $block = $rule->declarations->join('; '); + return "$selectors { $block; }$EOL"; +} + +function fmtr_padded($rule, $padding = 40) { + + $EOL = Crush::$process->newline; + + $selectors = $rule->selectors->join(', '); + $block = $rule->declarations->join('; '); + + if (strlen($selectors) > $padding) { + $padding = str_repeat(' ', $padding); + return "$selectors$EOL$padding { $block; }$EOL"; + } + else { + $selectors = str_pad($selectors, $padding); + return "$selectors { $block; }$EOL"; + } +} + +function fmtr_block($rule, $indent = ' ') { + + $EOL = Crush::$process->newline; + + $selectors = $rule->selectors->join(",$EOL"); + $block = $rule->declarations->join(";$EOL$indent"); + return "$selectors {{$EOL}$indent$block;$EOL$indent}$EOL"; +} diff --git a/misc/initial-values.ini b/misc/initial-values.ini deleted file mode 100644 index 8ef34e5..0000000 --- a/misc/initial-values.ini +++ /dev/null @@ -1,147 +0,0 @@ -; Sources: -; http://reference.sitepoint.com/css -; http://developer.mozilla.org/en/CSS - -animation = "none" -animation-delay = "0" -animation-direction = "normal" -animation-duration = "0" -animation-fill-mode = "none" -animation-iteration-count = "1" -animation-name = "none" -animation-play-state = "running" -animation-timing-function = "ease" -backface-visibility = "visible" -background = "0" -background-attachment = "scroll" -background-clip = "border-box" -background-color = "transparent" -background-image = "none" -background-origin = "padding-box" -background-position = "0 0" -background-repeat = "repeat" -background-size = "auto auto" -border = "0" -border-style = "none" -border-width = "medium" -border-color = "inherit" -border-bottom = "0" -border-bottom-color = "inherit" -border-bottom-left-radius = "0" -border-bottom-right-radius = "0" -border-bottom-style = "none" -border-bottom-width = "medium" -border-collapse = "separate" -border-image = "none" -border-left = "0" -border-left-color = "inherit" -border-left-style = "none" -border-left-width = "medium" -border-radius = "0" -border-right = "0" -border-right-color = "inherit" -border-right-style = "none" -border-right-width = "medium" -border-spacing = "0" -border-top = "0" -border-top-color = "inherit" -border-top-left-radius = "0" -border-top-right-radius = "0" -border-top-style = "none" -border-top-width = "medium" -bottom = "auto" -box-shadow = "none" -box-sizing = "content-box" -caption-side = "top" -clear = "none" -clip = "auto" -color = "inherit" -columns = "auto" -column-count = "auto" -column-fill = "balance" -column-gap = "normal" -column-rule = "medium none currentColor" -column-rule-color = "currentColor" -column-rule-style = "none" -column-rule-width = "none" -column-span = "1" -column-width = "auto" -content = "normal" -counter-increment = "none" -counter-reset = "none" -cursor = "auto" -direction = "ltr" -display = "inline" -empty-cells = "show" -float = "none" -font = "normal" -font-family = "inherit" -font-size = "medium" -font-style = "normal" -font-variant = "normal" -font-weight = "normal" -height = "auto" -hyphens = "none" -left = "auto" -letter-spacing = "normal" -line-height = "normal" -list-style = "none" -list-style-image = "none" -list-style-position = "outside" -list-style-type = "disc" -margin = "0" -margin-bottom = "0" -margin-left = "0" -margin-right = "0" -margin-top = "0" -max-height = "none" -max-width = "none" -min-height = "0" -min-width = "0" -opacity = "1" -orphans = "0" -outline = "0" -outline-color = "invert" -outline-style = "none" -outline-width = "medium" -overflow = "visible" -padding = "0" -padding-bottom = "0" -padding-left = "0" -padding-right = "0" -padding-top = "0" -page-break-after = "auto" -page-break-before = "auto" -page-break-inside = "auto" -perspective = "none" -perspective-origin = "50% 50%" -position = "static" -; May need to alter quotes for different locales (e.g fr) -quotes = "'\201C' '\201D' '\2018' '\2019'" -right = "auto" -tab-size = "8" -table-layout = "auto" -text-align = "inherit" -text-decoration = "none" -text-decoration-color = "inherit" -text-decoration-line = "none" -text-decoration-style = "solid" -text-indent = "0" -text-shadow = "none" -text-transform = "none" -top = "auto" -transform = "none" -transform-style = "flat" -transition = "none" -transition-delay = "0" -transition-duration = "0" -transition-property = "none" -transition-timing-function = "ease" -unicode-bidi = "normal" -vertical-align = "baseline" -visibility = "visible" -white-space = "normal" -widows = "0" -width = "auto" -word-spacing = "normal" -z-index = "auto" diff --git a/misc/property-sorting.ini b/misc/property-sorting.ini new file mode 100644 index 0000000..3dfab1c --- /dev/null +++ b/misc/property-sorting.ini @@ -0,0 +1,176 @@ +; Table for property sorting. +; Vendor prefixes are added at runtime. + +; Generated content +content +quotes + +; Positioning +position +z-index +top +right +bottom +left + +; Display +visibility +opacity +display +overflow +overflow-x +overflow-y +vertical-align + +; Floats +float +clear + +; Transforms +transform +transform-style +perspective +perspective-origin +backface-visibility + +; Box-model: dimensions +box-sizing +width +height +min-width +max-width +min-height +max-height + +; Box-model: padding +padding +padding-top +padding-right +padding-bottom +padding-left + +; Box-model: margins +margin +margin-top +margin-right +margin-bottom +margin-left + +; Box-model: borders +border +border-color +border-image +border-radius +border-style +border-width +border-top +border-top-color +border-top-left-radius +border-top-right-radius +border-top-style +border-top-width +border-right +border-right-color +border-right-style +border-right-width +border-bottom +border-bottom-color +border-bottom-style +border-bottom-left-radius +border-bottom-right-radius +border-bottom-width +border-left +border-left-color +border-left-style +border-left-width + +; Box-model: effects +box-shadow + +; Counters +counter-increment +counter-reset + +; Foreground color +color + +; Background +background +background-attachment +background-clip +background-color +background-image +background-origin +background-position +background-position-x +background-position-y +background-repeat +background-size + +; Text +direction +text-align +text-align-last +text-decoration +text-decoration-color +text-decoration-line +text-decoration-style +text-indent +text-overflow +text-shadow +text-transform + +; Fonts: general +font +font-family +font-size +font-style +font-weight +font-variant +line-height + +; Fonts: spacing and behaviour +letter-spacing +white-space +word-break +word-spacing +word-wrap +hyphens +orphans + +; Outlines +outline +outline-color +outline-offset +outline-style +outline-width + +; Animations +animation +animation-delay +animation-direction +animation-duration +animation-fill-mode +animation-iteration-count +animation-name +animation-play-state +animation-timing-function + +; Transitions +transition +transition-delay +transition-duration +transition-property +transition-timing-function + +; Tables specific +table-layout +border-collapse +caption-side +empty-cells + +; Lists specific +list-style +list-style-image +list-style-position +list-style-type diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0645924 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1697 @@ +{ + "name": "csscrush", + "version": "5.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "csscrush", + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "glob": "~8.0.3" + }, + "bin": { + "csscrush": "bin/csscrush" + }, + "devDependencies": { + "@types/node": "~20.4.9", + "eslint": "~8.16.0", + "normalize.css": "7.0.0", + "typescript": "~5.1.6" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.3.0.tgz", + "integrity": "sha512-UWW0TMTmk2d7hLcWD1/e2g5HDM/HQ3csaLSqXCfqwh4uNDuNqlaKWXmEsL4Cs41Z0KnILNvwbHAah3C2yt06kw==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.3.2", + "globals": "^13.15.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz", + "integrity": "sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw==", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^1.2.1", + "debug": "^4.1.1", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", + "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", + "dev": true + }, + "node_modules/@types/node": { + "version": "20.4.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.9.tgz", + "integrity": "sha512-8e2HYcg7ohnTUbHk8focoklEQYvemQmu9M/f43DZVx43kHn0tE3BY/6gSDxS7k0SprtS0NHvj+L80cGLnoOUcQ==", + "dev": true + }, + "node_modules/acorn": { + "version": "8.7.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.1.tgz", + "integrity": "sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.16.0.tgz", + "integrity": "sha512-MBndsoXY/PeVTDJeWsYj7kLZ5hQpJOfMYLsF6LicLHQWbRDG19lK5jOix4DPl8yY4SUFcE3txy86OzFLWT+yoA==", + "dev": true, + "dependencies": { + "@eslint/eslintrc": "^1.3.0", + "@humanwhocodes/config-array": "^0.9.2", + "ajv": "^6.10.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.1.1", + "eslint-utils": "^3.0.0", + "eslint-visitor-keys": "^3.3.0", + "espree": "^9.3.2", + "esquery": "^1.4.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "functional-red-black-tree": "^1.0.1", + "glob-parent": "^6.0.1", + "globals": "^13.15.0", + "ignore": "^5.2.0", + "import-fresh": "^3.0.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.1", + "regexpp": "^3.2.0", + "strip-ansi": "^6.0.1", + "strip-json-comments": "^3.1.0", + "text-table": "^0.2.0", + "v8-compile-cache": "^2.0.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", + "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/eslint-utils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", + "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^2.0.0" + }, + "engines": { + "node": "^10.0.0 || ^12.0.0 || >= 14.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=5" + } + }, + "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", + "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/espree": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.3.2.tgz", + "integrity": "sha512-D211tC7ZwouTIuY5x9XnS0E9sWNChB7IYKX/Xp5eQj3nFXhqmiUDB9q27y76oFl8jTg3pXcQx/bpxMfs3CIZbA==", + "dev": true, + "dependencies": { + "acorn": "^8.7.1", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/esquery": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz", + "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", + "dev": true + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flat-cache": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", + "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", + "dev": true, + "dependencies": { + "flatted": "^3.1.0", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.5.tgz", + "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==", + "dev": true + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", + "dev": true + }, + "node_modules/glob": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.0.3.tgz", + "integrity": "sha512-ull455NHSHI/Y1FqGaaYFaLGkNMMJbavMrEGFXG/PGrg6y7sutWHUHrz6gy6WEBH6akM1M414dWKCNs+IhKdiQ==", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.0.tgz", + "integrity": "sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globals": { + "version": "13.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.15.0.tgz", + "integrity": "sha512-bpzcOlgDhMG070Av0Vy5Owklpv1I6+j96GhUI7Rh7IzDCKLzboflLrrfqMu8NquDbiR4EOQk7XzJwqVJxicxog==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", + "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", + "dev": true + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", + "dev": true + }, + "node_modules/normalize.css": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/normalize.css/-/normalize.css-7.0.0.tgz", + "integrity": "sha1-q/sd2CRwZ04DIrU86xqvQSk45L8=", + "dev": true + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", + "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.3" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/regexpp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", + "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", + "dev": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", + "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/v8-compile-cache": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", + "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", + "dev": true + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", + "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + } + }, + "dependencies": { + "@eslint/eslintrc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.3.0.tgz", + "integrity": "sha512-UWW0TMTmk2d7hLcWD1/e2g5HDM/HQ3csaLSqXCfqwh4uNDuNqlaKWXmEsL4Cs41Z0KnILNvwbHAah3C2yt06kw==", + "dev": true, + "requires": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.3.2", + "globals": "^13.15.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + } + }, + "@humanwhocodes/config-array": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz", + "integrity": "sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw==", + "dev": true, + "requires": { + "@humanwhocodes/object-schema": "^1.2.1", + "debug": "^4.1.1", + "minimatch": "^3.0.4" + } + }, + "@humanwhocodes/object-schema": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", + "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", + "dev": true + }, + "@types/node": { + "version": "20.4.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.9.tgz", + "integrity": "sha512-8e2HYcg7ohnTUbHk8focoklEQYvemQmu9M/f43DZVx43kHn0tE3BY/6gSDxS7k0SprtS0NHvj+L80cGLnoOUcQ==", + "dev": true + }, + "acorn": { + "version": "8.7.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.1.tgz", + "integrity": "sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==", + "dev": true + }, + "acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "requires": {} + }, + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "requires": { + "esutils": "^2.0.2" + } + }, + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true + }, + "eslint": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.16.0.tgz", + "integrity": "sha512-MBndsoXY/PeVTDJeWsYj7kLZ5hQpJOfMYLsF6LicLHQWbRDG19lK5jOix4DPl8yY4SUFcE3txy86OzFLWT+yoA==", + "dev": true, + "requires": { + "@eslint/eslintrc": "^1.3.0", + "@humanwhocodes/config-array": "^0.9.2", + "ajv": "^6.10.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.1.1", + "eslint-utils": "^3.0.0", + "eslint-visitor-keys": "^3.3.0", + "espree": "^9.3.2", + "esquery": "^1.4.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "functional-red-black-tree": "^1.0.1", + "glob-parent": "^6.0.1", + "globals": "^13.15.0", + "ignore": "^5.2.0", + "import-fresh": "^3.0.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.1", + "regexpp": "^3.2.0", + "strip-ansi": "^6.0.1", + "strip-json-comments": "^3.1.0", + "text-table": "^0.2.0", + "v8-compile-cache": "^2.0.3" + } + }, + "eslint-scope": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", + "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==", + "dev": true, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + } + }, + "eslint-utils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", + "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^2.0.0" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true + } + } + }, + "eslint-visitor-keys": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", + "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", + "dev": true + }, + "espree": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.3.2.tgz", + "integrity": "sha512-D211tC7ZwouTIuY5x9XnS0E9sWNChB7IYKX/Xp5eQj3nFXhqmiUDB9q27y76oFl8jTg3pXcQx/bpxMfs3CIZbA==", + "dev": true, + "requires": { + "acorn": "^8.7.1", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.3.0" + } + }, + "esquery": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz", + "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==", + "dev": true, + "requires": { + "estraverse": "^5.1.0" + } + }, + "esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "requires": { + "estraverse": "^5.2.0" + } + }, + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true + }, + "esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", + "dev": true + }, + "file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "requires": { + "flat-cache": "^3.0.4" + } + }, + "flat-cache": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", + "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", + "dev": true, + "requires": { + "flatted": "^3.1.0", + "rimraf": "^3.0.2" + } + }, + "flatted": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.5.tgz", + "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==", + "dev": true + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + }, + "functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", + "dev": true + }, + "glob": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.0.3.tgz", + "integrity": "sha512-ull455NHSHI/Y1FqGaaYFaLGkNMMJbavMrEGFXG/PGrg6y7sutWHUHrz6gy6WEBH6akM1M414dWKCNs+IhKdiQ==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "dependencies": { + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "requires": { + "balanced-match": "^1.0.0" + } + }, + "minimatch": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.0.tgz", + "integrity": "sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg==", + "requires": { + "brace-expansion": "^2.0.1" + } + } + } + }, + "glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "requires": { + "is-glob": "^4.0.3" + } + }, + "globals": { + "version": "13.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.15.0.tgz", + "integrity": "sha512-bpzcOlgDhMG070Av0Vy5Owklpv1I6+j96GhUI7Rh7IzDCKLzboflLrrfqMu8NquDbiR4EOQk7XzJwqVJxicxog==", + "dev": true, + "requires": { + "type-fest": "^0.20.2" + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "ignore": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", + "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==", + "dev": true + }, + "import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "requires": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + } + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true + }, + "is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "requires": { + "argparse": "^2.0.1" + } + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", + "dev": true + }, + "levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + } + }, + "lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", + "dev": true + }, + "normalize.css": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/normalize.css/-/normalize.css-7.0.0.tgz", + "integrity": "sha1-q/sd2CRwZ04DIrU86xqvQSk45L8=", + "dev": true + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "requires": { + "wrappy": "1" + } + }, + "optionator": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", + "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", + "dev": true, + "requires": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.3" + } + }, + "parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "requires": { + "callsites": "^3.0.0" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, + "prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true + }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "dev": true + }, + "regexpp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", + "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", + "dev": true + }, + "resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + }, + "dependencies": { + "glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + } + } + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + }, + "strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", + "dev": true + }, + "type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1" + } + }, + "type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true + }, + "typescript": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", + "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "dev": true + }, + "uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "v8-compile-cache": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", + "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", + "dev": true + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "word-wrap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", + "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "dev": true + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1b24543 --- /dev/null +++ b/package.json @@ -0,0 +1,37 @@ +{ + "name": "csscrush", + "version": "5.0.0", + "description": "CSS-Crush, CSS preprocessor", + "main": "./js/index.js", + "types": "./js/index.d.ts", + "repository": { + "type": "git", + "url": "https://github.com/peteboere/css-crush.git" + }, + "bugs": { + "url": "https://github.com/peteboere/css-crush/issues" + }, + "bin": { + "csscrush": "./bin/csscrush" + }, + "scripts": { + "lint": "eslint --fix ./js/index.js", + "types": "npx -p typescript tsc -p jsconfig.json", + "test": "node ./js/tests/test.js" + }, + "homepage": "http://the-echoplex.net/csscrush", + "license": "MIT", + "devDependencies": { + "@types/node": "~20.4.9", + "eslint": "~8.16.0", + "normalize.css": "7.0.0", + "typescript": "~5.1.6" + }, + "dependencies": { + "glob": "~8.0.3" + }, + "type": "module", + "engines": { + "node": ">=18" + } +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..2a70c1f --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + level: 1 + paths: + - lib + - plugins diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..e7d271e --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,20 @@ + + + + + + + + + ./tests/unit/ + + + + + + ./lib/CssCrush/ + + + diff --git a/plugins/aria.php b/plugins/aria.php new file mode 100644 index 0000000..e8408ed --- /dev/null +++ b/plugins/aria.php @@ -0,0 +1,70 @@ + $handler) { + $type = is_callable($handler) ? 'callback' : 'alias'; + $process->addSelectorAlias($name, $handler, $type); + } +}); + +function aria() { + + static $aria, $optional_value; + if (! $aria) { + $optional_value = function ($property) { + return function ($args) use ($property) { + return $args ? "[$property=\"#(0)\"]" : "[$property]"; + }; + }; + $aria = [ + + // Roles. + 'role' => $optional_value('role'), + + // States and properties. + 'aria-activedescendant' => $optional_value('aria-activedescendant'), + 'aria-atomic' => '[aria-atomic="#(0 true)"]', + 'aria-autocomplete' => $optional_value('aria-autocomplete'), + 'aria-busy' => '[aria-busy="#(0 true)"]', + 'aria-checked' => '[aria-checked="#(0 true)"]', + 'aria-controls' => $optional_value('aria-controls'), + 'aria-describedby' => $optional_value('aria-describedby'), + 'aria-disabled' => '[aria-disabled="#(0 true)"]', + 'aria-dropeffect' => $optional_value('aria-dropeffect'), + 'aria-expanded' => '[aria-expanded="#(0 true)"]', + 'aria-flowto' => $optional_value('aria-flowto'), + 'aria-grabbed' => '[aria-grabbed="#(0 true)"]', + 'aria-haspopup' => '[aria-haspopup="#(0 true)"]', + 'aria-hidden' => '[aria-hidden="#(0 true)"]', + 'aria-invalid' => '[aria-invalid="#(0 true)"]', + 'aria-label' => $optional_value('aria-label'), + 'aria-labelledby' => $optional_value('aria-labelledby'), + 'aria-level' => $optional_value('aria-level'), + 'aria-live' => $optional_value('aria-live'), + 'aria-multiline' => '[aria-multiline="#(0 true)"]', + 'aria-multiselectable' => '[aria-multiselectable="#(0 true)"]', + 'aria-orientation' => $optional_value('aria-orientation'), + 'aria-owns' => $optional_value('aria-owns'), + 'aria-posinset' => $optional_value('aria-posinset'), + 'aria-pressed' => '[aria-pressed="#(0 true)"]', + 'aria-readonly' => '[aria-readonly="#(0 true)"]', + 'aria-relevant' => $optional_value('aria-relevant'), + 'aria-required' => '[aria-required="#(0 true)"]', + 'aria-selected' => '[aria-selected="#(0 true)"]', + 'aria-setsize' => $optional_value('aria-setsize'), + 'aria-sort' => $optional_value('aria-sort'), + 'aria-valuemax' => $optional_value('aria-valuemax'), + 'aria-valuemin' => $optional_value('aria-valuemin'), + 'aria-valuenow' => $optional_value('aria-valuenow'), + 'aria-valuetext' => $optional_value('aria-valuetext'), + ]; + } + + return $aria; +} diff --git a/plugins/canvas.php b/plugins/canvas.php new file mode 100644 index 0000000..2b08a28 --- /dev/null +++ b/plugins/canvas.php @@ -0,0 +1,659 @@ +on('capture_phase2', 'CssCrush\canvas_capture'); + $process->functions->add('canvas', 'CssCrush\canvas_generator'); + $process->functions->add('canvas-data', 'CssCrush\canvas_generator'); +}); + +function canvas_capture($process) { + + $process->string->pregReplaceCallback( + Regex::make('~@canvas\s+(?{{ ident }})\s*{{ block }}~iS'), + function ($m) { + Crush::$process->misc->canvas_defs[strtolower($m['name'])] = new Template($m['block_content']); + return ''; + }); +} + +function canvas_generator($input, $context) { + + $process = Crush::$process; + + // Check GD requirements are met. + static $requirements; + if (! isset($requirements)) { + $requirements = canvas_requirements(); + } + if ($requirements === false) { + return ''; + } + + // Check process cache. + $cache_key = $context->function . $input; + if (isset($process->misc->canvas_cache[$cache_key])) { + return $process->misc->canvas_cache[$cache_key]; + } + + // Parse args, bail if none. + $args = Functions::parseArgs($input); + if (! isset($args[0])) { + return ''; + } + + $name = strtolower(array_shift($args)); + + // Bail if name not registered. + $canvas_defs =& $process->misc->canvas_defs; + if (! isset($canvas_defs[$name])) { + return ''; + } + + // Apply args to template. + $block = $canvas_defs[$name]($args); + + $raw = DeclarationList::parse($block, [ + 'keyed' => true, + 'lowercase_keys' => true, + 'flatten' => true, + 'apply_hooks' => true, + ]); + + // Create canvas object. + $canvas = new Canvas(); + + // Parseable canvas attributes with default values. + static $schema = [ + 'fill' => null, + 'background-fill' => null, + 'src' => null, + 'canvas-filter' => null, + 'width' => null, + 'height' => null, + 'margin' => 0, + ]; + + // Resolve properties, set defaults if not present. + $canvas->raw = array_intersect_key($raw, $schema) + $schema; + + // Pre-populate. + canvas_preprocess($canvas); + + // Apply functions. + canvas_apply_css_funcs($canvas); + // debug($canvas); + + // Create fingerprint for this canvas based on canvas object. + $fingerprint = substr(md5(serialize($canvas)), 0, 7); + $generated_filename = "cnv-$name-$fingerprint.png"; + + if (! empty($process->options->asset_dir)) { + $generated_filepath = $process->options->asset_dir . '/' . $generated_filename; + $generated_url = Util::getLinkBetweenPaths( + $process->output->dir, $process->options->asset_dir) . $generated_filename; + } + else { + $generated_filepath = $process->output->dir . '/' . $generated_filename; + $generated_url = $generated_filename; + } + $cached_file = file_exists($generated_filepath); + + // $cached_file = false; + if (! $cached_file) { + + // Source arguments take priority. + if ($src = canvas_fetch_src($canvas->raw['src'])) { + + // Resolve the src image dimensions and positioning. + $dst_w = $src->width; + $dst_h = $src->height; + if (isset($canvas->width) && isset($canvas->height)) { + $dst_w = $canvas->width; + $dst_h = $canvas->height; + } + elseif (isset($canvas->width)) { + $dst_w = $canvas->width; + $dst_h = ($src->height/$src->width) * $canvas->width; + } + elseif (isset($canvas->height)) { + $dst_w = ($src->width/$src->height) * $canvas->height; + $dst_h = $canvas->height; + } + + // Update the canvas height and width based on the src. + $canvas->width = $dst_w; + $canvas->height = $dst_h; + + // Create base. + canvas_create($canvas); + + // Apply background layer. + canvas_fill($canvas, 'background-fill'); + + // Filters. + canvas_apply_filters($canvas, $src); + + // Place the src image on the base canvas image. + imagecopyresized( + $canvas->image, // dest_img + $src->image, // src_img + $canvas->margin->left, // dst_x + $canvas->margin->top, // dst_y + 0, // src_x + 0, // src_y + $dst_w, // dst_w + $dst_h, // dst_h + $src->width, // src_w + $src->height // src_h + ); + imagedestroy($src->image); + } + else { + + // Set defaults. + $canvas->width = isset($canvas->width) ? intval($canvas->width) : 100; + $canvas->height = isset($canvas->height) ? intval($canvas->height) : 100; + $canvas->fills += ['fill' => 'black']; + + // Create base. + canvas_create($canvas); + + // Apply background layer. + canvas_fill($canvas, 'background-fill'); + canvas_fill($canvas, 'fill'); + } + } + else { + // debug('file cached'); + } + + + // Either write to a file. + if ($context->function === 'canvas' && $process->ioContext === 'file') { + + if (! $cached_file) { + imagepng($canvas->image, $generated_filepath); + } + + $url = new Url($generated_url); + $url->noRewrite = true; + } + // Or create data uri. + else { + if (! $cached_file) { + ob_start(); + imagepng($canvas->image); + $data = ob_get_clean(); + } + else { + $data = file_get_contents($generated_filepath); + } + + $url = new Url('data:image/png;base64,' . base64_encode($data)); + } + + $label = $process->tokens->add($url); + + // Cache the output URL. + $process->misc->canvas_cache[$cache_key] = $label; + + return $label; +} + + +function canvas_fn_linear_gradient($input, $context) { + + $args = Functions::parseArgs($input) + ['white', 'black']; + + $first_arg = strtolower($args[0]); + + static $directions = [ + 'to top' => ['vertical', true], + 'to right' => ['horizontal', false], + 'to bottom' => ['vertical', false], + 'to left' => ['horizontal', true], + ]; + + if (isset($directions[$first_arg])) { + list($direction, $flip) = $directions[$first_arg]; + array_shift($args); + } + else { + list($direction, $flip) = $directions['to bottom']; + } + + // Create fill object. + $fill = new stdClass(); + $fill->stops = []; + $fill->direction = $direction; + + canvas_set_fill_dims($fill, $context->canvas); + + // Start color. + $color = Color::parse($args[0]); + $fill->stops[] = $color ? $color : [0, 0, 0, 1]; + + // End color. + $color = Color::parse($args[1]); + $fill->stops[] = $color ? $color : [255, 255, 255, 1]; + + if ($flip) { + $fill->stops = array_reverse($fill->stops); + } + + $context->canvas->fills[$context->currentProperty] = $fill; +} + +function canvas_fn_filter($input, $context) { + + $args = Functions::parseArgs($input); + + array_unshift($context->canvas->filters, [$context->function, $args]); +} + + +function canvas_apply_filters($canvas, $src) { + + foreach ($canvas->filters as $filter) { + list($name, $args) = $filter; + + switch ($name) { + case 'greyscale': + case 'grayscale': + imagefilter($src->image, IMG_FILTER_GRAYSCALE); + break; + + case 'invert': + imagefilter($src->image, IMG_FILTER_NEGATE); + break; + + case 'opacity': + canvas_fade($src, floatval($args[0])); + break; + + case 'colorize': + $rgb = $args + ['black']; + if (count($rgb) === 1) { + // If only one argument parse it as a CSS color value. + $rgb = Color::parse($rgb[0]); + if (! $rgb) { + $rgb = [0, 0, 0]; + } + } + imagefilter($src->image, IMG_FILTER_COLORIZE, $rgb[0], $rgb[1], $rgb[2]); + break; + + case 'blur': + $level = 1; + if (isset($args[0])) { + // Allow multiple blurs for a stronger effect. + // Set hard limit. + $level = min(max(intval($args[0]), 1), 20); + } + while ($level--) { + imagefilter($src->image, IMG_FILTER_GAUSSIAN_BLUR); + } + break; + + case 'contrast': + if (isset($args[0])) { + // By default it works like this: + // (max) -100 <- 0 -> +100 (min) + // But we're flipping the polarity to be more predictable: + // (min) -100 <- 0 -> +100 (max) + $level = intval($args[0]) * -1; + } + imagefilter($src->image, IMG_FILTER_CONTRAST, $level); // @phpstan-ignore-line variable.undefined + break; + + case 'brightness': + if (isset($args[0])) { + // -255 <- 0 -> +255 + $level = intval($args[0]); + } + imagefilter($src->image, IMG_FILTER_BRIGHTNESS, $level); // @phpstan-ignore-line variable.undefined + break; + } + } +} + +function canvas_apply_css_funcs($canvas) { + + static $functions; + if (! $functions) { + $functions = new stdClass(); + + $functions->fill = new Functions(['canvas-linear-gradient' => 'CssCrush\canvas_fn_linear_gradient']); + + $functions->generic = new Functions(array_diff_key(Crush::$process->functions->register, $functions->fill->register)); + + $functions->filter = new Functions([ + 'contrast' => 'CssCrush\canvas_fn_filter', + 'opacity' => 'CssCrush\canvas_fn_filter', + 'colorize' => 'CssCrush\canvas_fn_filter', + 'grayscale' => 'CssCrush\canvas_fn_filter', + 'greyscale' => 'CssCrush\canvas_fn_filter', + 'brightness' => 'CssCrush\canvas_fn_filter', + 'invert' => 'CssCrush\canvas_fn_filter', + 'blur' => 'CssCrush\canvas_fn_filter', + ]); + } + + $context = new stdClass(); + + foreach ($canvas->raw as $property => &$value) { + + if (! is_string($value)) { + continue; + } + + $value = $functions->generic->apply($value); + $context->canvas = $canvas; + + if (in_array($property, ['fill', 'background-fill'])) { + $context->currentProperty = $property; + $value = $functions->fill->apply($value, $context); + } + elseif ($property === 'canvas-filter') { + $value = $functions->filter->apply($value, $context); + } + } +} + +function canvas_preprocess($canvas) { + + if (isset($canvas->raw['margin'])) { + + $parts = canvas_parselist($canvas->raw['margin']); + $count = count($parts); + if ($count === 1) { + $margin = [$parts[0], $parts[0], $parts[0], $parts[0]]; + } + elseif ($count === 2) { + $margin = [$parts[0], $parts[1], $parts[0], $parts[1]]; + } + elseif ($count === 3) { + $margin = [$parts[0], $parts[1], $parts[2], $parts[1]]; + } + else { + $margin = $parts; + } + } + else { + $margin = [0, 0, 0, 0]; + } + + foreach (['fill', 'background-fill'] as $fill_name) { + if (isset($canvas->raw[$fill_name])) { + $canvas->fills[$fill_name] = $canvas->raw[$fill_name]; + } + } + + $canvas->margin = (object) [ + 'top' => $margin[0], + 'right' => $margin[1], + 'bottom' => $margin[2], + 'left' => $margin[3], + ]; + $canvas->width = $canvas->raw['width']; + $canvas->height = $canvas->raw['height']; +} + +function canvas_fetch_src($url_token) { + + if ($url_token && $url = Crush::$process->tokens->get($url_token)) { + + $file = $url->getAbsolutePath(); + + // Testing the image availability and getting info. + if ($info = @getimagesize($file)) { + + $image = null; + + // If image is available copy it. + switch ($info['mime']) { + case 'image/png': + $image = imagecreatefrompng($file); + break; + case 'image/jpg': + case 'image/jpeg': + $image = imagecreatefromjpeg($file); + break; + case 'image/gif': + $image = imagecreatefromgif($file); + break; + case 'image/webp': + $image = imagecreatefromwebp($file); + break; + } + if ($image) { + return (object) [ + 'file' => $file, + 'info' => $info, + 'width' => $info[0], + 'height' => $info[1], + 'image' => $image, + ]; + } + } + } + return false; +} + + +/* + Adapted from GD Gradient Fill by Ozh (http://planetozh.com): + http://planetozh.com/blog/my-projects/images-php-gd-gradient-fill +*/ +function canvas_gradient($canvas, $fill) { + + $image = $canvas->image; + + // Resolve drawing direction. + if ($fill->direction === 'horizontal') { + $line_numbers = $fill->x2 - $fill->x1; + } + else { + $line_numbers = $fill->y2 - $fill->y1; + } + + list($r1, $g1, $b1, $a1) = $fill->stops[0]; + list($r2, $g2, $b2, $a2) = $fill->stops[1]; + + $r = $g = $b = $a = -1; + + for ($line = 0; $line < $line_numbers; $line++) { + + $last = "$r,$g,$b,$a"; + + $r = $r2 - $r1 ? intval($r1 + ($r2 - $r1) * ($line / $line_numbers)): $r1; + $g = $g2 - $g1 ? intval($g1 + ($g2 - $g1) * ($line / $line_numbers)): $g1; + $b = $b2 - $b1 ? intval($b1 + ($b2 - $b1) * ($line / $line_numbers)): $b1; + $a = $a2 - $a1 ? ($a1 + ($a2 - $a1) * ($line / $line_numbers)) : $a1; + $a = canvas_opacity($a); + + if ($last != "$r,$g,$b,$a") { + $color = imagecolorallocatealpha($image, $r, $g, $b, $a); + } + + switch($fill->direction) { + case 'horizontal': + imagefilledrectangle($image, + $fill->x1 + $line, + $fill->y1, + $fill->x1 + $line, + $fill->y2, + $color); // @phpstan-ignore-line variable.undefined + + break; + case 'vertical': + default: + imagefilledrectangle($image, + $fill->x1, + $fill->y1 + $line, + $fill->x2, + $fill->y1 + $line, + $color); // @phpstan-ignore-line variable.undefined + break; + } + imagealphablending($image, true); + } +} + +function canvas_create($canvas) { + + $margin = $canvas->margin; + $width = $canvas->width + $margin->right + $margin->left; + $height = $canvas->height + $margin->top + $margin->bottom; + + // Create image object. + $canvas->image = canvas_create_transparent($width, $height); +} + +function canvas_create_transparent($width, $height) { + + $image = imagecreatetruecolor($width, $height); + + // Set transparent canvas background. + imagealphablending($image, false); + imagesavealpha($image, true); + imagefill($image, 0, 0, imagecolorallocatealpha($image, 0, 0, 0, 127)); + + return $image; +} + +function canvas_fade($src, $opacity) { + + $width = imagesx($src->image); + $height = imagesy($src->image); + $new_image = canvas_create_transparent($width, $height); + $opacity = canvas_opacity($opacity); + + // Perform pixel-based alpha map application + for ($x = 0; $x < $width; $x++) { + for ($y = 0; $y < $height; $y++) { + $colors = imagecolorsforindex($src->image, imagecolorat($src->image, $x, $y)); + imagesetpixel($new_image, $x, $y, imagecolorallocatealpha( + $new_image, $colors['red'], $colors['green'], $colors['blue'], $opacity)); + } + } + + imagedestroy($src->image); + $src->image = $new_image; +} + + +function canvas_fill($canvas, $property) { + + if (! isset($canvas->fills[$property])) { + return false; + } + $fill = $canvas->fills[$property]; + + // Gradient fill. + if (is_object($fill)) { + canvas_gradient($canvas, $fill); + } + + // Solid color fill. + elseif ($solid = Color::parse($fill)) { + + list($r, $g, $b, $a) = $solid; + $color = imagecolorallocatealpha($canvas->image, $r, $g, $b, canvas_opacity($a)); + + $fill = new stdClass(); + $canvas->currentProperty = $property; + canvas_set_fill_dims($fill, $canvas); + + imagefilledrectangle($canvas->image, $fill->x1, $fill->y1, $fill->x2, $fill->y2, $color); + imagealphablending($canvas->image, true); + } + + // Can't parse. + else { + return false; + } +} + +function canvas_set_fill_dims($fill, $canvas) { + + // Resolve fill dimensions and coordinates. + $margin = $canvas->margin; + + $fill->x1 = 0; + $fill->y1 = 0; + $fill->x2 = $canvas->width + $margin->right + $margin->left; + $fill->y2 = $canvas->height + $margin->top + $margin->bottom; + + if (isset($canvas->currentProperty) && $canvas->currentProperty === 'fill') { + $fill->x1 = $margin->left; + $fill->y1 = $margin->top; + $fill->x2 = $canvas->width + $fill->x1 - 1; + $fill->y2 = $canvas->height + $fill->y1 - 1; + } +} + +function canvas_requirements() { + + $requirements_met = true; + + if (! extension_loaded('gd')) { + $requirements_met = false; + warning('GD extension not available.'); + } + else { + $gd_info = implode('|', array_keys(array_filter(gd_info()))); + + foreach (['jpe?g' => 'JPG', 'png' => 'PNG'] as $file_ext_patt => $file_ext) { + if (! preg_match("~\b(?$file_ext_patt) support\b~i", $gd_info)) { + $requirements_met = false; + warning("GD extension has no $file_ext support."); + } + } + } + + return $requirements_met; +} + + +/* + Canvas object. +*/ +class Canvas +{ + public $currentProperty; + public $fills = []; + public $filters = []; + public $height; + public $image; + public $margin; + public $raw; + public $width; + + public function __destruct() + { + if (isset($this->image)) { + imagedestroy($this->image); + } + } +} + +/* + Helpers. +*/ +function canvas_opacity($float) { + return 127 - max(min(round($float * 127), 127), 0); +} + +function canvas_parselist($str, $numbers = true) { + $list = preg_split('~ +~', trim($str)); + return $numbers ? array_map('floatval', $list) : $list; +} diff --git a/plugins/double-colon.php b/plugins/double-colon.php deleted file mode 100644 index b480e8a..0000000 --- a/plugins/double-colon.php +++ /dev/null @@ -1,18 +0,0 @@ -selector_raw = preg_replace( '!::(after|before|first-letter|first-line)!', ':$1', $rule->selector_raw ); -} diff --git a/plugins/ease.php b/plugins/ease.php new file mode 100644 index 0000000..fce8ad6 --- /dev/null +++ b/plugins/ease.php @@ -0,0 +1,65 @@ +on('rule_prealias', 'CssCrush\ease'); +}); + +function ease(Rule $rule) { + + static $find, $replace, $easing_properties; + if (! $find) { + $easings = [ + 'ease-in-out-back' => 'cubic-bezier(.680,-0.550,.265,1.550)', + 'ease-in-out-circ' => 'cubic-bezier(.785,.135,.150,.860)', + 'ease-in-out-expo' => 'cubic-bezier(1,0,0,1)', + 'ease-in-out-sine' => 'cubic-bezier(.445,.050,.550,.950)', + 'ease-in-out-quint' => 'cubic-bezier(.860,0,.070,1)', + 'ease-in-out-quart' => 'cubic-bezier(.770,0,.175,1)', + 'ease-in-out-cubic' => 'cubic-bezier(.645,.045,.355,1)', + 'ease-in-out-quad' => 'cubic-bezier(.455,.030,.515,.955)', + 'ease-out-back' => 'cubic-bezier(.175,.885,.320,1.275)', + 'ease-out-circ' => 'cubic-bezier(.075,.820,.165,1)', + 'ease-out-expo' => 'cubic-bezier(.190,1,.220,1)', + 'ease-out-sine' => 'cubic-bezier(.390,.575,.565,1)', + 'ease-out-quint' => 'cubic-bezier(.230,1,.320,1)', + 'ease-out-quart' => 'cubic-bezier(.165,.840,.440,1)', + 'ease-out-cubic' => 'cubic-bezier(.215,.610,.355,1)', + 'ease-out-quad' => 'cubic-bezier(.250,.460,.450,.940)', + 'ease-in-back' => 'cubic-bezier(.600,-0.280,.735,.045)', + 'ease-in-circ' => 'cubic-bezier(.600,.040,.980,.335)', + 'ease-in-expo' => 'cubic-bezier(.950,.050,.795,.035)', + 'ease-in-sine' => 'cubic-bezier(.470,0,.745,.715)', + 'ease-in-quint' => 'cubic-bezier(.755,.050,.855,.060)', + 'ease-in-quart' => 'cubic-bezier(.895,.030,.685,.220)', + 'ease-in-cubic' => 'cubic-bezier(.550,.055,.675,.190)', + 'ease-in-quad' => 'cubic-bezier(.550,.085,.680,.530)', + ]; + + $easing_properties = [ + 'transition' => true, + 'transition-timing-function' => true, + ]; + + foreach ($easings as $property => $value) { + $patt = Regex::make("~{{ LB }}$property{{ RB }}~i"); + $find[] = $patt; + $replace[] = $value; + } + } + + if (! array_intersect_key($rule->declarations->canonicalProperties, $easing_properties)) { + return; + } + + foreach ($rule->declarations->filter(['skip' => false]) as $declaration) { + if (isset($easing_properties[$declaration->canonicalProperty])) { + $declaration->value = preg_replace($find, $replace, $declaration->value); + } + } +} diff --git a/plugins/forms.php b/plugins/forms.php new file mode 100644 index 0000000..466e4e3 --- /dev/null +++ b/plugins/forms.php @@ -0,0 +1,33 @@ + $handler) { + if (is_array($handler)) { + $type = $handler['type']; + $handler = $handler['handler']; + } + $process->addSelectorAlias($name, $handler, $type); // @phpstan-ignore-line variable.undefined + } +}); + +function forms() { + return [ + 'input' => [ + 'type' => 'splat', + 'handler' => 'input[type=#(text)]', + ], + 'checkbox' => 'input[type="checkbox"]', + 'radio' => 'input[type="radio"]', + 'file' => 'input[type="file"]', + 'image' => 'input[type="image"]', + 'password' => 'input[type="password"]', + 'submit' => 'input[type="submit"]', + 'text' => 'input[type="text"]', + ]; +} diff --git a/plugins/hocus-pocus.php b/plugins/hocus-pocus.php index 50a5593..a7cda1d 100644 --- a/plugins/hocus-pocus.php +++ b/plugins/hocus-pocus.php @@ -1,24 +1,10 @@ ':any(:hover,:focus)$1', - '!:pocus([^a-z0-9_-])!' => ':any(:hover,:focus,:active)$1', - ); - $rule->selector_raw = preg_replace( array_keys( $adjustments ), array_values( $adjustments ), $rule->selector_raw ); -} +csscrush_plugin('hocus-pocus', function ($process) { + $process->addSelectorAlias('hocus', ':any(:hover,:focus)'); + $process->addSelectorAlias('pocus', ':any(:hover,:focus,:active)'); +}); diff --git a/plugins/hsl-to-hex.php b/plugins/hsl-to-hex.php deleted file mode 100644 index 47d85ee..0000000 --- a/plugins/hsl-to-hex.php +++ /dev/null @@ -1,32 +0,0 @@ -skip and - ( !empty( $declaration->functions ) and in_array( 'hsl', $declaration->functions ) ) - ) { - while ( preg_match( '!hsl(___p\d+___)!', $declaration->value, $m ) ) { - $full_match = $m[0]; - $token = $m[1]; - $hsl = trim( $rule->parens[ $token ], '()' ); - $hsl = array_map( 'trim', explode( ',', $hsl ) ); - $rgb = CssCrush_Color::cssHslToRgb( $hsl ); - $hex = CssCrush_Color::rgbToHex( $rgb ); - $declaration->value = str_replace( $full_match, $hex, $declaration->value ); - } - } - } -} \ No newline at end of file diff --git a/plugins/ie-clip.php b/plugins/ie-clip.php deleted file mode 100644 index 2316522..0000000 --- a/plugins/ie-clip.php +++ /dev/null @@ -1,33 +0,0 @@ -propertyCount( 'clip' ) !== 1 ) { - return; - } - $new_set = array(); - foreach ( $rule as $declaration ) { - $new_set[] = $declaration; - if ( - $declaration->skip or - $declaration->property !== 'clip' - ) { - continue; - } - $new_set[] = $rule->createDeclaration( - '*clip', str_replace( ',', ' ', $rule->getDeclarationValue( $declaration ) ) ); - } - $rule->declarations = $new_set; -} \ No newline at end of file diff --git a/plugins/ie-filter.php b/plugins/ie-filter.php deleted file mode 100644 index 2bcd06c..0000000 --- a/plugins/ie-filter.php +++ /dev/null @@ -1,55 +0,0 @@ - 7 - * Outputs '*' escaped filter property for IE < 8 - * Adds hasLayout via zoom property (required by filter effects) - * - * - * @before - * -ms-filter: alpha(opacity=50), blur(strength=10); - * - * @after - * -ms-filter: "alpha(opacity=50), progid:DXImageTransform.Microsoft.Blur(strength=10)"; - * *filter: alpha(opacity=50), progid:DXImageTransform.Microsoft.Blur(strength=10); - * zoom: 1; - */ - -CssCrush_Hook::add( 'rule_postalias', 'csscrush_filter' ); - -function csscrush_filter ( CssCrush_Rule $rule ) { - if ( $rule->propertyCount( '-ms-filter' ) < 1 ) { - return; - } - $filter_prefix = 'progid:DXImageTransform.Microsoft.'; - $new_set = array(); - foreach ( $rule as $declaration ) { - if ( - $declaration->skip or - $declaration->property !== '-ms-filter' - ) { - $new_set[] = $declaration; - continue; - } - $list = array_map( 'trim', explode( ',', $declaration->value ) ); - foreach ( $list as &$item ) { - if ( - strpos( $item, $filter_prefix ) !== 0 and - strpos( $item, 'alpha' ) !== 0 // Shortcut syntax permissable on alpha - ) { - $item = $filter_prefix . ucfirst( $item ); - } - } - $declaration->value = implode( ',', $list ); - if ( !$rule->propertyCount( 'zoom' ) ) { - // Filters need hasLayout - $new_set[] = $rule->createDeclaration( 'zoom', 1 ); - } - // Quoted version for -ms-filter IE >= 8 - $new_set[] = $rule->createDeclaration( '-ms-filter', "\"$declaration->value\"" ); - // Star escaped property for IE < 8 - $new_set[] = $rule->createDeclaration( '*filter', $declaration->value ); - } - $rule->declarations = $new_set; -} \ No newline at end of file diff --git a/plugins/ie-inline-block.php b/plugins/ie-inline-block.php deleted file mode 100644 index 0fbf415..0000000 --- a/plugins/ie-inline-block.php +++ /dev/null @@ -1,34 +0,0 @@ -propertyCount( 'display' ) < 1 ) { - return; - } - $new_set = array(); - foreach ( $rule as $declaration ) { - $new_set[] = $declaration; - $is_display = $declaration->property === 'display'; - if ( - $declaration->skip or - !$is_display or - $is_display and $declaration->value !== 'inline-block' ) { - continue; - } - $new_set[] = $rule->createDeclaration( '*display', 'inline' ); - $new_set[] = $rule->createDeclaration( '*zoom', 1 ); - } - $rule->declarations = $new_set; -} \ No newline at end of file diff --git a/plugins/ie-min-height.php b/plugins/ie-min-height.php deleted file mode 100644 index 11d3f0f..0000000 --- a/plugins/ie-min-height.php +++ /dev/null @@ -1,30 +0,0 @@ -propertyCount( 'min-height' ) < 1 ) { - return; - } - $new_set = array(); - foreach ( $rule as $declaration ) { - $new_set[] = $declaration; - if ( - $declaration->skip or - $declaration->property !== 'min-height' ) { - continue; - } - $new_set[] = $rule->createDeclaration( '_height', $declaration->value ); - } - $rule->declarations = $new_set; -} \ No newline at end of file diff --git a/plugins/ie-opacity.php b/plugins/ie-opacity.php deleted file mode 100755 index 1504f32..0000000 --- a/plugins/ie-opacity.php +++ /dev/null @@ -1,43 +0,0 @@ -propertyCount( 'opacity' ) < 1 ) { - return; - } - $new_set = array(); - foreach ( $rule as $declaration ) { - $new_set[] = $declaration; - if ( - $declaration->skip or - $declaration->property != 'opacity' - ) { - continue; - } - - $opacity = (float) $declaration->value; - $opacity = round( $opacity * 100 ); - - if ( !$rule->propertyCount( 'zoom' ) ) { - // Filters need hasLayout - $new_set[] = $rule->createDeclaration( 'zoom', 1 ); - } - $value = "alpha(opacity=$opacity)"; - $new_set[] = $rule->createDeclaration( '-ms-filter', "\"$value\"" ); - $new_set[] = $rule->createDeclaration( '*filter', $value ); - } - $rule->declarations = $new_set; -} \ No newline at end of file diff --git a/plugins/initial.php b/plugins/initial.php deleted file mode 100644 index 12df893..0000000 --- a/plugins/initial.php +++ /dev/null @@ -1,41 +0,0 @@ -skip and 'initial' === $declaration->value ) { - if ( isset( $initialValues[ $declaration->property ] ) ) { - $declaration->value = $initialValues[ $declaration->property ]; - } - else { - // Fallback to 'inherit' - $declaration->value = 'inherit'; - } - } - } -} \ No newline at end of file diff --git a/plugins/property-sorter.php b/plugins/property-sorter.php new file mode 100644 index 0000000..8930d66 --- /dev/null +++ b/plugins/property-sorter.php @@ -0,0 +1,160 @@ +on('rule_prealias', 'CssCrush\property_sorter'); + }); + + function property_sorter(Rule $rule) { + + usort($rule->declarations->store, 'CssCrush\property_sorter_callback'); + } + + + /* + Callback for sorting. + */ + function property_sorter_callback($a, $b) { + + $map =& property_sorter_get_table(); + $a_prop =& $a->canonicalProperty; + $b_prop =& $b->canonicalProperty; + $a_listed = isset($map[$a_prop]); + $b_listed = isset($map[$b_prop]); + + // If the properties are identical we need to flag for an index comparison. + $compare_indexes = false; + + // If the 'canonical' properties are identical we need to flag for a vendor comparison. + $compare_vendor = false; + + // If both properties are listed. + if ($a_listed && $b_listed) { + + if ($a_prop === $b_prop) { + if ($a->vendor || $b->vendor) { + $compare_vendor = true; + } + else { + $compare_indexes = true; + } + } + else { + // Table comparison. + return $map[$a_prop] > $map[$b_prop] ? 1 : -1; + } + } + + // If one property is listed it always takes higher priority. + elseif ($a_listed && ! $b_listed) { + return -1; + } + elseif ($b_listed && ! $a_listed) { + return 1; + } + + // If neither property is listed. + else { + + if ($a_prop === $b_prop) { + if ($a->vendor || $b->vendor) { + $compare_vendor = true; + } + else { + $compare_indexes = true; + } + } + else { + // Regular sort. + return $a_prop > $b_prop ? 1 : -1; + } + } + + // Comparing by index. + if ($compare_indexes ) { + return $a->index > $b->index ? 1 : -1; + } + + // Comparing by vendor mark. + if ($compare_vendor) { + if (! $a->vendor && $b->vendor) { + return 1; + } + elseif ($a->vendor && ! $b->vendor) { + return -1; + } + else { + // If both have a vendor mark compare vendor name length. + return strlen($b->vendor) > strlen($a->vendor) ? 1 : -1; + } + } + } + + + /* + Cache for the table of values to compare against. + */ + function &property_sorter_get_table () { + + // Check for cached table. + if (isset($GLOBALS['CSSCRUSH_PROPERTY_SORT_ORDER_CACHE'])) { + return $GLOBALS['CSSCRUSH_PROPERTY_SORT_ORDER_CACHE']; + } + + $table = []; + + // Nothing cached, check for a user-defined table. + if (isset($GLOBALS['CSSCRUSH_PROPERTY_SORT_ORDER'])) { + $table = (array) $GLOBALS['CSSCRUSH_PROPERTY_SORT_ORDER']; + } + + // No user-defined table, use pre-defined. + else { + + // Load from property-sorting.ini. + $sorting_file_contents = file_get_contents(Crush::$dir . '/misc/property-sorting.ini'); + if ($sorting_file_contents !== false) { + + $sorting_file_contents = preg_replace('~;[^\r\n]*~', '', $sorting_file_contents); + $table = preg_split('~\s+~', trim($sorting_file_contents)); + } + else { + notice("Property sorting file not found."); + } + + // Store to the global variable. + $GLOBALS['CSSCRUSH_PROPERTY_SORT_ORDER'] = $table; + } + + // Cache the table (and flip it). + $GLOBALS['CSSCRUSH_PROPERTY_SORT_ORDER_CACHE'] = array_flip($table); + + return $GLOBALS['CSSCRUSH_PROPERTY_SORT_ORDER_CACHE']; + } + +} + +namespace { + + /* + Get the current sorting table. + */ + function csscrush_get_property_sort_order() { + CssCrush\property_sorter_get_table(); + return $GLOBALS['CSSCRUSH_PROPERTY_SORT_ORDER']; + } + + + /* + Set a custom sorting table. + */ + function csscrush_set_property_sort_order(array $new_order) { + unset($GLOBALS['CSSCRUSH_PROPERTY_SORT_ORDER_CACHE']); + $GLOBALS['CSSCRUSH_PROPERTY_SORT_ORDER'] = $new_order; + } +} diff --git a/plugins/rgba-fallback.php b/plugins/rgba-fallback.php deleted file mode 100644 index 71da318..0000000 --- a/plugins/rgba-fallback.php +++ /dev/null @@ -1,52 +0,0 @@ -properties ); - - // Determine which properties apply - $rgba_props = array(); - foreach ( $props as $prop ) { - if ( $prop === 'background' or strpos( $prop, 'color' ) !== false ) { - $rgba_props[] = $prop; - } - } - if ( empty( $rgba_props ) ) { - return; - } - - $new_set = array(); - foreach ( $rule as $declaration ) { - $is_viable = in_array( $declaration->property, $rgba_props ); - if ( - $declaration->skip or - !$is_viable or - $is_viable and !preg_match( '!^rgba___p\d+___$!', $declaration->value ) - ) { - $new_set[] = $declaration; - continue; - } - // Create rgb value from rgba - $raw_value = $rule->getDeclarationValue( $declaration ); - $raw_value = substr( $raw_value, 5, strlen( $raw_value ) - 1 ); - list( $r, $g, $b, $a ) = explode( ',', $raw_value ); - - // Add rgb value to the stack, followed by rgba - $new_set[] = $rule->createDeclaration( $declaration->property, "rgb($r,$g,$b)" ); - $new_set[] = $declaration; - } - $rule->declarations = $new_set; -} diff --git a/plugins/svg.php b/plugins/svg.php new file mode 100644 index 0000000..9fdbef9 --- /dev/null +++ b/plugins/svg.php @@ -0,0 +1,1105 @@ +on('capture_phase2', 'CssCrush\svg_capture'); + $process->functions->add('svg', 'CssCrush\fn__svg'); + $process->functions->add('svg-data', 'CssCrush\fn__svg_data'); + $process->functions->add('svg-linear-gradient', 'CssCrush\fn__svg_linear_gradient'); + $process->functions->add('svg-radial-gradient', 'CssCrush\fn__svg_radial_gradient'); +}); + +function fn__svg($input) { + + return svg_generator($input, 'svg'); +} + +function fn__svg_data($input) { + + return svg_generator($input, 'svg-data'); +} + +function svg_capture($process) { + + $process->string->pregReplaceCallback( + Regex::make('~@svg\s+(?{{ ident }})\s*{{ block }}~iS'), + function ($m) { + Crush::$process->misc->svg_defs[strtolower($m['name'])] = new Template($m['block_content']); + return ''; + }); +} + +function svg_generator($input, $fn_name) { + + $process = Crush::$process; + + $cache_key = $fn_name . $input; + if (isset($process->misc->svg_cache[$cache_key])) { + + return $process->misc->svg_cache[$cache_key]; + } + + // Map types to element names. + static $schemas; + if (! $schemas) { + $schemas = [ + 'circle' => [ + 'tag' => 'circle', + 'attrs' => 'cx cy r', + ], + 'ellipse' => [ + 'tag' => 'ellipse', + 'attrs' => 'cx cy rx ry', + ], + 'rect' => [ + 'tag' => 'rect', + 'attrs' => 'x y rx ry width height', + ], + 'polygon' => [ + 'tag' => 'polygon', + 'attrs' => 'points', + ], + 'line' => [ + 'tag' => 'line', + 'attrs' => 'x1 y1 x2 y2', + ], + 'polyline' => [ + 'tag' => 'polyline', + 'attrs' => 'points', + ], + 'path' => [ + 'tag' => 'path', + 'attrs' => 'd', + ], + 'star' => [ + 'tag' => 'path', + 'attrs' => '', + ], + 'text' => [ + 'tag' => 'text', + 'attrs' => 'x y dx dy rotate', + ], + ]; + + // Convert attributes to keyed array. + // Add global attributes. + foreach ($schemas as $type => &$schema) { + $schema['attrs'] = array_flip(explode(' ', $schema['attrs'])) + + ['transform' => true]; + } + } + + // Non standard attributes. + static $custom_attrs = [ + 'type' => true, + 'data' => true, + 'twist' => true, + 'diameter' => true, + 'corner-radius' => true, + 'star-points' => true, + 'margin' => true, + 'drop-shadow' => true, + 'sides' => true, + 'text' => true, + 'width' => true, + 'height' => true, + ]; + + // Bail if no args. + $args = Functions::parseArgs($input); + if (! isset($args[0])) { + + return ''; + } + + $name = strtolower(array_shift($args)); + + // Bail if no SVG registered by this name. + $svg_defs =& $process->misc->svg_defs; + if (! isset($svg_defs[$name])) { + + return ''; + } + + // Apply args to template. + $block = $svg_defs[$name]($args); + + $raw_data = DeclarationList::parse($block, [ + 'keyed' => true, + 'lowercase_keys' => true, + 'flatten' => true, + 'apply_hooks' => true, + ]); + + // Resolve the type. + // Bail if type not recognised. + $type = isset($raw_data['type']) ? strtolower($raw_data['type']) : 'path'; + if (! isset($schemas[$type])) { + + return ''; + } + + // Create element object for attaching all required rendering data. + $element = (object) [ + 'tag' => $schemas[$type]['tag'], + 'fills' => [ + 'gradients' => [], + 'patterns' => [], + ], + 'filters' => [], + 'data' => [], + 'attrs' => [], + 'styles' => [], + 'svg_attrs' => [ + 'xmlns' => 'http://www.w3.org/2000/svg', + ], + 'svg_styles' => [], + 'face_styles' => [], + ]; + + // Filter off prefixed properties that are for the svg element or @font-face. + foreach ($raw_data as $property => $value) { + if (strpos($property, 'svg-') === 0) { + $element->svg_styles[substr($property, 4)] = $value; + unset($raw_data[$property]); + } + elseif (strpos($property, 'face-') === 0) { + $element->face_styles[substr($property, 5)] = $value; + unset($raw_data[$property]); + } + } + + svg_apply_css_funcs($element, $raw_data); + + // Initialize element attributes. + $element->attrs = array_intersect_key($raw_data, $schemas[$type]['attrs']); + $element->data = array_intersect_key($raw_data, $custom_attrs); + + // Everything else is treated as CSS. + $element->styles = array_diff_key($raw_data, $custom_attrs, $schemas[$type]['attrs']); + + // Pre-populate common attributes. + svg_preprocess($element); + + // Filters. + svg_apply_filters($element); + + // Apply element type callback. + call_user_func("CssCrush\svg_$type", $element); + + // Apply optimizations. + svg_compress($element); + + // Build markup. + $svg = svg_render($element); + + // Debugging... + // $code = implode("\n", $svg); + // $test = '
' . htmlspecialchars($code) . '
'; + // echo $test; + + // Either write to a file. + if ($fn_name === 'svg' && $process->ioContext === 'file') { + + $flattened_svg = implode("\n", $svg); + + // Create fingerprint for the created file. + $fingerprint = substr(md5($flattened_svg), 0, 7); + $generated_filename = "svg-$name-$fingerprint.svg"; + + if (! empty($process->options->asset_dir)) { + $generated_filepath = $process->options->asset_dir . '/' . $generated_filename; + $generated_url = Util::getLinkBetweenPaths( + $process->output->dir, $process->options->asset_dir) . $generated_filename; + } + else { + $generated_filepath = $process->output->dir . '/' . $generated_filename; + $generated_url = $generated_filename; + } + + Util::filePutContents($generated_filepath, $flattened_svg); + + $url = new Url($generated_url); + $url->noRewrite = true; + } + // Or create data uri. + else { + $url = new Url('data:image/svg+xml;base64,' . base64_encode(implode('', $svg))); + } + + // Cache the output URL. + $label = $process->tokens->add($url); + $process->misc->svg_cache[$cache_key] = $label; + + return $label; +} + + +/* + Circle callback. +*/ +function svg_circle($element) { + + // Ensure required attributes have defaults set. + $element->data += [ + 'diameter' => 50, + ]; + + list($margin_top, $margin_right, $margin_bottom, $margin_left) = $element->data['margin']; + + $element->attrs['r'] = + $radius = svg_ifset($element->attrs['r'], $element->data['diameter'] / 2); + + $diameter = $radius * 2; + + $element->attrs['cx'] = svg_ifset($element->attrs['cx'], $margin_left + $radius); + $element->attrs['cy'] = svg_ifset($element->attrs['cy'], $margin_top + $radius); + + $element->svg_attrs['width'] = $margin_left + $diameter + $margin_right; + $element->svg_attrs['height'] = $margin_top + $diameter + $margin_bottom; +} + +/* + Rect callback. +*/ +function svg_rect($element) { + + $element->data += [ + 'width' => 50, + 'height' => 50, + ]; + + list($margin_top, $margin_right, $margin_bottom, $margin_left) = $element->data['margin']; + + $element->attrs['x'] = $margin_left; + $element->attrs['y'] = $margin_top; + $element->attrs['width'] = $element->data['width']; + $element->attrs['height'] = $element->data['height']; + + if (isset($element->data['corner-radius'])) { + $args = svg_parselist($element->data['corner-radius']); + $element->attrs['rx'] = isset($args[0]) ? $args[0] : 0; + $element->attrs['ry'] = isset($args[1]) ? $args[1] : $args[0]; + } + + $element->svg_attrs['width'] = $margin_left + $element->data['width'] + $margin_right; + $element->svg_attrs['height'] = $margin_top + $element->data['height'] + $margin_bottom; +} + +/* + Ellipse callback. +*/ +function svg_ellipse($element) { + + $element->data += [ + 'diameter' => '100 50', + ]; + + if (! isset($element->attrs['rx']) && ! isset($element->attrs['ry'])) { + $diameter = svg_parselist($element->data['diameter']); + $element->attrs['rx'] = $diameter[0] / 2; + $element->attrs['ry'] = isset($diameter[1]) ? $diameter[1] / 2 : $diameter[0] / 2; + } + + list($margin_top, $margin_right, $margin_bottom, $margin_left) = $element->data['margin']; + + $element->attrs['cx'] = $margin_left + $element->attrs['rx']; + $element->attrs['cy'] = $margin_top + $element->attrs['ry']; + + $element->svg_attrs['width'] = $margin_left + ($element->attrs['rx'] * 2) + $margin_right; + $element->svg_attrs['height'] = $margin_top + ($element->attrs['ry'] * 2) + $margin_bottom; +} + +/* + Path callback. +*/ +function svg_path($element) { + + // Ensure minimum required attributes have defaults set. + $element->data += [ + 'd' => 'M 10,10 l 10,0 l 0,10 l 10,0 l 0,10', + ]; + + // Unclosed paths have implicit fill. + $element->styles += [ + 'fill' => 'none', + ]; +} + +/* + Polyline callback. +*/ +function svg_polyline($element) { + + // Ensure required attributes have defaults set. + $element->data += [ + 'points' => '20,20 40,20 40,40 60,40 60,60', + ]; + + // Polylines have implicit fill. + $element->styles += [ + 'fill' => 'none', + ]; +} + +/* + Line callback. +*/ +function svg_line($element) { + + // Set a default stroke. + $element->styles += [ + 'stroke' => '#000', + ]; + + $element->attrs += [ + 'x1' => 0, + 'x2' => 0, + 'y1' => 0, + 'y2' => 0, + ]; +} + +/* + Polygon callback. +*/ +function svg_polygon($element) { + + if (! isset($element->attrs['points'])) { + + // Switch to path element. + $element->tag = 'path'; + + $element->data += [ + 'sides' => 3, + 'diameter' => 100, + ]; + + list($margin_top, $margin_right, $margin_bottom, $margin_left) = $element->data['margin']; + + $diameter = svg_parselist($element->data['diameter']); + $diameter = $diameter[0]; + $radius = $diameter / 2; + + $cx = $radius + $margin_left; + $cy = $radius + $margin_top; + $sides = $element->data['sides']; + + $element->attrs['d'] = svg_starpath($cx, $cy, $sides, $radius); + + $element->svg_attrs['width'] = $diameter + $margin_left + $margin_right; + $element->svg_attrs['height'] = $diameter + $margin_top + $margin_bottom; + } +} + +/* + Star callback. +*/ +function svg_star($element) { + + // Minimum required attributes have defaults. + $element->data += [ + 'star-points' => 4, + 'diameter' => '50 30', + 'twist' => 0, + ]; + + list($margin_top, $margin_right, $margin_bottom, $margin_left) = $element->data['margin']; + + $diameter = svg_parselist($element->data['diameter']); + if (! isset($diameter[1])) { + $diameter[1] = ($diameter[0] / 2); + } + $outer_r = $diameter[0] / 2; + $inner_r = $diameter[1] / 2; + + $cx = $outer_r + $margin_left; + $cy = $outer_r + $margin_top; + $points = $element->data['star-points']; + $twist = $element->data['twist'] * 10; + + $element->attrs['d'] = svg_starpath($cx, $cy, $points, $outer_r, $inner_r, $twist); + + $element->svg_attrs['width'] = $margin_left + ($outer_r * 2) + $margin_left; + $element->svg_attrs['height'] = $margin_top + ($outer_r * 2) + $margin_bottom; +} + +/* + Text callback. + Warning: Very limited for svg-as-image situations. +*/ +function svg_text($element) { + + // Minimum required attributes have defaults. + $element->data += [ + 'x' => 0, + 'y' => 0, + 'width' => 100, + 'height' => 100, + 'text' => '', + ]; + + $text = Crush::$process->tokens->restore($element->data['text'], 's', true); + + // Remove open and close quotes. + $text = substr($text, 1, strlen($text) - 2); + + // Convert CSS unicode sequences to XML unicode. + $text = preg_replace('~\\\\([[:xdigit:]]{2,6})~', '&#x$1;', $text); + + // Remove excape slashes and encode meta entities. + $text = htmlentities(stripslashes($text), ENT_QUOTES, 'UTF-8', false); + $element->data['text'] = $text; + + $element->svg_attrs['width'] = $element->data['width']; + $element->svg_attrs['height'] = $element->data['height']; +} + + + +/* + Star/polygon path builder. + + Adapted from http://svg-whiz.com/svg/StarMaker.svg by Doug Schepers. +*/ +function svg_starpath($cx, $cy, $points, $outer_r, $inner_r = null, $twist = 0, $orient = 'point') { + + $d = []; + + // Enforce minimum number of points. + $points = max(3, $points); + + for ($s = 0; $points >= $s; $s++) { + + // Outer angle. + $outer_angle = 2.0 * M_PI * ($s / $points); + + if ($orient === 'point') { + $outer_angle -= (M_PI / 2); + } + elseif ($orient === 'edge') { + $outer_angle = ($outer_angle + (M_PI / $points)) - (M_PI / 2); + } + + // Outer point based on outer angle. + $x = ( $outer_r * cos($outer_angle) ) + $cx; + $y = ( $outer_r * sin($outer_angle) ) + $cy; + + if ($points != $s) { + $d[] = "$x $y"; + } + + // If star shape is required need inner angles too. + if ($inner_r != null && $points != $s) { + + $inner_angle = (2 * M_PI * ($s / $points)) + (M_PI / $points); + + if ($orient === 'point') { + $inner_angle -= (M_PI / 2); + } + $inner_angle += $twist; + + $ix = ( $inner_r * cos($inner_angle) ) + $cx; + $iy = ( $inner_r * sin($inner_angle) ) + $cy; + + $d[] = "$ix $iy"; + } + } + + return 'M' . implode('L', $d) . 'Z'; +} + +function svg_apply_filters($element) { + + if (isset($element->data['drop-shadow'])) { + + $parts = svg_parselist($element->data['drop-shadow'], false); + + list($ds_x, $ds_y, $ds_strength, $ds_color) = $parts += [ + 2, // x offset. + 2, // y offset. + 2, // strength. + 'black', // color. + ]; + + // Opacity. + $drop_shadow_opacity = null; + if ($color_components = Color::colorSplit($ds_color)) { + list($ds_color, $drop_shadow_opacity) = $color_components; + } + + $filter = ''; + $filter .= ""; + $filter .= ""; + $filter .= ""; + $filter .= ""; + if (isset($drop_shadow_opacity)) { + $filter .= ''; + $filter .= ""; + $filter .= ''; + } + $filter .= ''; + $filter .= ''; + $filter .= ''; + $filter .= ''; + $filter .= ''; + + $element->styles['filter'] = 'url(#f)'; + $element->filters[] = $filter; + } +} + +function svg_preprocess($element) { + + if (isset($element->data['margin'])) { + + $margin =& $element->data['margin']; + + $parts = svg_parselist($margin); + $count = count($parts); + if ($count === 1) { + $margin = [$parts[0], $parts[0], $parts[0], $parts[0]]; + } + elseif ($count === 2) { + $margin = [$parts[0], $parts[1], $parts[0], $parts[1]]; + } + elseif ($count === 3) { + $margin = [$parts[0], $parts[1], $parts[2], $parts[1]]; + } + else { + $margin = $parts; + } + } + else { + $element->data['margin'] = [0, 0, 0, 0]; + } + + // 'Unzip' string tokens on data attributes. + foreach (['points', 'd'] as $point_data_attr) { + + if (isset($element->attrs[$point_data_attr])) { + + $value = $element->attrs[$point_data_attr]; + + if (Tokens::is($value, 's')) { + $element->attrs[$point_data_attr] = + trim(Crush::$process->tokens->get($value), '"\'');; + } + } + } + + if (isset($element->data['width'])) { + $element->svg_attrs['width'] = $element->data['width']; + } + if (isset($element->data['height'])) { + $element->svg_attrs['height'] = $element->data['height']; + } +} + +function svg_apply_css_funcs($element, &$raw_data) { + + // Setup functions for using on values. + // Note using custom versions of svg-*-gradient(). + static $functions; + if (! $functions) { + $functions = new \stdClass(); + $functions->fill = new Functions([ + 'svg-linear-gradient' => 'CssCrush\svg_fn_linear_gradient', + 'svg-radial-gradient' => 'CssCrush\svg_fn_radial_gradient', + 'pattern' => 'CssCrush\svg_fn_pattern', + ]); + + $functions->generic = new Functions(array_diff_key(Crush::$process->functions->register, $functions->fill->register)); + } + + foreach ($raw_data as $property => &$value) { + $value = $functions->generic->apply($value); + + // Only capturing fills for fill and stoke properties. + if ($property === 'fill' || $property === 'stroke') { + $value = $functions->fill->apply($value, $element); + + // If the value is a color with alpha component we split the color + // and set the corresponding *-opacity property because Webkit doesn't + // support rgba()/hsla() in SVG. + if ($components = Color::colorSplit($value)) { + list($color, $opacity) = $components; + $raw_data[$property] = $color; + if ($opacity < 1) { + $raw_data += ["$property-opacity" => $opacity]; + } + } + } + } +} + +function svg_compress($element) { + + foreach ($element->attrs as $key => &$value) { + + // Compress numbers on data attributes. + if (in_array($key, ['points', 'd'])) { + $value = preg_replace_callback( + Regex::$patt->number, + function ($m) { return round($m[0], 2); }, + $value); + } + } +} + +function svg_render($element) { + + // Flatten styles. + $styles = ''; + $styles_data = [ + '@font-face' => $element->face_styles, + 'svg' => $element->svg_styles, + ]; + foreach ($styles_data as $selector => $declarations) { + if ($declarations) { + $out = []; + foreach ($declarations as $property => $value) { + $out[] = "$property:$value"; + } + $styles .= $selector . '{' . implode(';', $out) . '}'; + } + } + $styles = Crush::$process->tokens->restore($styles, ['u', 's'], true); + + // Add element styles as attributes which tend to work better with svg2png converters. + $attrs = Util::htmlAttributes($element->attrs + $element->styles); + + // Add viewbox to help IE scale correctly. + if (isset($element->svg_attrs['width']) && isset($element->svg_attrs['height'])) { + $element->svg_attrs += [ + 'viewbox' => implode(' ', [ + 0, + 0, + $element->svg_attrs['width'], + $element->svg_attrs['height'] + ]), + ]; + } + $svg_attrs = Util::htmlAttributes($element->svg_attrs); + + // Markup. + $svg[] = ""; + + if ( + $element->fills['gradients'] || + $element->fills['patterns'] || + $element->filters || + $styles + ) { + $svg[] = ''; + $svg[] = implode($element->fills['gradients']); + $svg[] = implode($element->fills['patterns']); + $svg[] = implode($element->filters); + if ($styles) { + $cdata = preg_match('~[<>&]~', $styles); + $svg[] = ''; + } + $svg[] = ''; + } + + if ($element->tag === 'text') { + $svg[] = "{$element->data['text']}"; + } + else { + $svg[] = "<{$element->tag}$attrs/>"; + } + $svg[] = ''; + + return array_filter($svg, 'strlen'); +} + + +/* + Custom versions of svg-*-gradient() for integrating. +*/ +function svg_fn_linear_gradient($input, $element) { + + $generated_gradient = create_svg_linear_gradient($input); + $element->fills['gradients'][] = reset($generated_gradient); + + return 'url(#' . key($generated_gradient) . ')'; +} + +function svg_fn_radial_gradient($input, $element) { + + $generated_gradient = create_svg_radial_gradient($input); + $element->fills['gradients'][] = reset($generated_gradient); + + return 'url(#' . key($generated_gradient) . ')'; +} + +function svg_fn_pattern($input, $element) { + + $pid = 'p'; + + // Get args in order with defaults. + list($url, $transform_list, $width, $height, $x, $y) = + Functions::parseArgs($input) + + ['', '', 0, 0, 0, 0]; + + $url = Crush::$process->tokens->get($url); + if (! $url) { + return ''; + } + + // If $width or $height is not specified get image dimensions the slow way. + if (! $width || ! $height) { + $file = $url->getAbsolutePath(); + list($width, $height) = getimagesize($file); + } + + // If a data-uri function has been used. + if ($url->convertToData) { + $url->toData(); + } + + $transform_list = $transform_list ? " patternTransform=\"$transform_list\"" : ''; + $generated_pattern = ""; + $generated_pattern .= "value}\" x=\"$x\" y=\"$y\" width=\"$width\" height=\"$height\"/>"; + $generated_pattern .= ''; + + $element->fills['patterns'][] = $generated_pattern; + $element->svg_attrs['xmlns:xlink'] = "http://www.w3.org/1999/xlink"; + + return 'url(#' . $pid . ')'; +} + + +/* + Helpers. +*/ +function svg_parselist($str, $numbers = true) { + $list = preg_split('~ +~', trim($str)); + return $numbers ? array_map('floatval', $list) : $list; +} + +function svg_ifset(&$var, $fallback = null) { + if (isset($var)) { + return $var; + } + return $fallback; +} + + +/* + SVG gradients. +*/ +function fn__svg_linear_gradient($input) { + + $gradient = create_svg_linear_gradient($input); + $gradient_markup = reset($gradient); + $gradient_id = key($gradient); + + $svg = ''; + $svg .= ''; + $svg .= $gradient_markup; + $svg .= ''; + $svg .= ""; + $svg .= ''; + + return Crush::$process->tokens->add(new Url('data:image/svg+xml;base64,' . base64_encode($svg))); +} + + +function fn__svg_radial_gradient($input) { + + $gradient = create_svg_radial_gradient($input); + $gradient_markup = reset($gradient); + $gradient_id = key($gradient); + + $svg = ''; + $svg .= ''; + $svg .= $gradient_markup; + $svg .= ''; + $svg .= ""; + $svg .= ''; + + return Crush::$process->tokens->add(new Url('data:image/svg+xml;base64,' . base64_encode($svg))); +} + + +function create_svg_linear_gradient($input) { + + static $angle_keywords, $deg_patt; + if (! $angle_keywords) { + $angle_keywords = [ + 'to top' => 180, + 'to right' => 270, + 'to bottom' => 0, + 'to left' => 90, + // Not very magic corners. + 'to top right' => [[0, 100], [100, 0]], + 'to top left' => [[100, 100], [0, 0]], + 'to bottom right' => [[0, 0], [100, 100]], + 'to bottom left' => [[100, 0], [0, 100]], + ]; + $angle_keywords['to right top'] = $angle_keywords['to top right']; + $angle_keywords['to left top'] = $angle_keywords['to top left']; + $angle_keywords['to right bottom'] = $angle_keywords['to bottom right']; + $angle_keywords['to left bottom'] = $angle_keywords['to bottom left']; + + $deg_patt = Regex::make('~^{{number}}deg$~i'); + } + + $args = Functions::parseArgs($input); + + // If no angle argument is passed the default. + $angle = 0; + + // Parse starting and ending coordinates from the first argument if it's an angle. + $coords = null; + $first_arg = $args[0]; + $first_arg_is_angle = false; + + // Try to parse an angle value. + if (preg_match($deg_patt, $first_arg)) { + $angle = floatval($first_arg); + + // Quick fix to match standard linear-gradient() angle. + $angle += 180; + $first_arg_is_angle = true; + } + elseif (isset($angle_keywords[$first_arg])) { + if (is_array($angle_keywords[$first_arg])) { + $coords = $angle_keywords[$first_arg]; + } + else { + $angle = $angle_keywords[$first_arg]; + } + $first_arg_is_angle = true; + } + + // Shift off the first argument if it has been recognised as an angle. + if ($first_arg_is_angle) { + array_shift($args); + } + + // If not using a magic corner, create start/end coordinates from the angle. + if (! $coords) { + + // Normalize the angle. + $angle = fmod($angle, 360); + if ($angle < 0) { + $angle = 360 + $angle; + } + $angle = round($angle, 2); + + $start_x = 0; + $end_x = 0; + $start_y = 0; + $end_y = 100; + + if ($angle >= 0 && $angle <= 45) { + $start_x = (($angle / 45) * 50) + 50; + $end_x = 100 - $start_x; + $start_y = 0; + $end_y = 100; + } + elseif ($angle > 45 && $angle <= 135) { + $angle_delta = $angle - 45; + $start_x = 100; + $end_x = 0; + $start_y = ($angle_delta / 90) * 100; + $end_y = 100 - $start_y; + } + elseif ($angle > 135 && $angle <= 225) { + $angle_delta = $angle - 135; + $start_x = 100 - (($angle_delta / 90) * 100); + $end_x = 100 - $start_x; + $start_y = 100; + $end_y = 0; + } + elseif ($angle > 225 && $angle <= 315) { + $angle_delta = $angle - 225; + $start_x = 0; + $end_x = 100; + $start_y = 100 - (($angle_delta / 90) * 100); + $end_y = 100 - $start_y; + } + elseif ($angle > 315 && $angle <= 360) { + $angle_delta = $angle - 315; + $start_x = ($angle_delta / 90) * 100; + $end_x = 100 - $start_x; + $start_y = 0; + $end_y = 100; + } + $coords = [ + [round($start_x, 1), round($start_y, 1)], + [round($end_x, 1), round($end_y, 1)], + ]; + } + + // The remaining arguments are treated as color stops. + // - Capture their color values and if specified color offset percentages. + // - Only percentages are supported as SVG gradients to accept other length values + // for color stop offsets. + $color_stops = parse_gradient_color_stops($args); + + $gradient_id = "lg"; + $gradient = ""; + $gradient .= $color_stops; + $gradient .= ''; + + return [$gradient_id => $gradient]; +} + + +function create_svg_radial_gradient($input) { + + static $position_keywords, $origin_patt; + if (! $position_keywords) { + $position_keywords = [ + 'at top' => ['50%', '0%'], + 'at right' => ['100%', '50%'], + 'at bottom' => ['50%', '100%'], + 'at left' => ['0%', '50%'], + 'at center' => ['50%', '50%'], + // Not very magic corners. + 'at top right' => ['100%', '0%'], + 'at top left' => ['0%', '0%'], + 'at bottom right' => ['100%', '100%'], + 'at bottom left' => ['0%', '100%'], + ]; + $position_keywords['at right top'] = $position_keywords['at top right']; + $position_keywords['at left top'] = $position_keywords['at top left']; + $position_keywords['at right bottom'] = $position_keywords['at bottom right']; + $position_keywords['at left bottom'] = $position_keywords['at bottom left']; + + $origin_patt = Regex::make('~^({{number}}%?) +({{number}}%?)$~'); + } + + $args = Functions::parseArgs($input); + + // Default origin, + $position = $position_keywords['at center']; + + // Parse origin coordinates from the first argument if it's an origin. + $first_arg = $args[0]; + $first_arg_is_position = false; + + // Try to parse an origin value. + if (preg_match($origin_patt, $first_arg, $m)) { + $position = [$m[1], $m[2]]; + $first_arg_is_position = true; + } + elseif (isset($position_keywords[$first_arg])) { + $position = $position_keywords[$first_arg]; + $first_arg_is_position = true; + } + + // Shift off the first argument if it has been recognised as an origin. + if ($first_arg_is_position) { + array_shift($args); + } + + // The remaining arguments are treated as color stops. + // - Capture their color values and if specified color offset percentages. + // - Only percentages are supported as SVG gradients to accept other length values + // for color stop offsets. + $color_stops = parse_gradient_color_stops($args); + + $gradient_id = "rg"; + $gradient = ""; + $gradient .= $color_stops; + $gradient .= ''; + + return [$gradient_id => $gradient]; +} + + +function parse_gradient_color_stops(array $color_stop_args) { + + $offsets = []; + $colors = []; + $offset_patt = '~ +([\d\.]+%)$~'; + $last_index = count($color_stop_args) - 1; + + foreach ($color_stop_args as $index => $color_arg) { + + if (preg_match($offset_patt, $color_arg, $m)) { + $offsets[] = floatval($m[1]); + $color = preg_replace($offset_patt, '', $color_arg); + } + else { + if ($index === 0) { + $offsets[] = 0; + } + elseif ($index === $last_index) { + $offsets[] = 100; + } + else { + $offsets[] = null; + } + $color = $color_arg; + } + + // For hsla()/rgba() extract alpha component from color values and + // convert to hsl()/rgb(). + // Webkit doesn't support them for SVG colors. + $colors[] = Color::colorSplit($color); + } + + // For unspecified color offsets fill in the blanks. + $next_index_not_null = 0; + $prev_index_not_null = 0; + $n = count($offsets); + + foreach ($offsets as $index => $offset) { + + if (! isset($offset)) { + + // Scan for next non-null offset. + for ($i = $index; $i < $n; $i++) { + if (isset($offsets[$i])) { + $next_index_not_null = $i; + break; + } + } + + // Get the difference between previous 'not null' offset and the next 'not null' offset. + // Divide by the number of null offsets to get a value for padding between them. + $padding_increment = + ($offsets[$next_index_not_null] - $offsets[$prev_index_not_null]) / + ($next_index_not_null - $index + 1); + $padding = $padding_increment; + + for ($i = $index; $i < $n; $i++) { + if (isset($offsets[$i])) { + break; + } + // Replace the null offset with the new padded value. + $offsets[$i] = $offsets[$prev_index_not_null] + $padding; + // Bump the padding for the next null offset. + $padding += $padding_increment; + } + } + else { + $prev_index_not_null = $index; + } + } + + $stops = ''; + foreach (array_combine($offsets, $colors) as $offset => $color) { + list($color_value, $opacity) = $color; + $stop_opacity = $opacity < 1 ? " stop-opacity=\"$opacity\"" : ''; + $stops .= ""; + } + + return $stops; +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..08a9b9b --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,41 @@ +add('CssCrush\UnitTest', __DIR__ . '/unit'); +} + +namespace CssCrush\UnitTest +{ + function bootstrap_process($options = []) + { + $process = \CssCrush\Crush::$process = new \CssCrush\Process($options); + $process->preCompile(); + return $process; + } + + function temp_file($contents = '') + { + $temporary_file = tempnam(sys_get_temp_dir(), 'crush'); + if ($contents) { + file_put_contents($temporary_file, $contents); + } + return $temporary_file; + } + + function stdout($message, $prepend_newline = false, $append_newline = true) + { + if (! is_string($message)) { + ob_start(); + print_r($message); + $message = ob_get_clean(); + } + fwrite(STDOUT, ($prepend_newline ? "\n" : '') . $message . ($append_newline ? "\n" : '')); + } +} diff --git a/tests/unit/CssCrush/BalancedMatchTest.php b/tests/unit/CssCrush/BalancedMatchTest.php new file mode 100644 index 0000000..d7880f9 --- /dev/null +++ b/tests/unit/CssCrush/BalancedMatchTest.php @@ -0,0 +1,37 @@ +process = bootstrap_process(); + $sample = '@foo; @bar {color: orange;} @baz'; + + $this->process->string = new \CssCrush\StringObject($sample); + } + + public function testMatch() + { + $matches = $this->process->string->matchAll('~@bar~'); + $match_offset = $matches[0][0][1]; + + $match = new BalancedMatch($this->process->string, $match_offset); + + $this->assertEquals('color: orange;', $match->inside()); + $this->assertEquals('@bar {color: orange;}', $match->whole()); + + $match = new BalancedMatch(clone $this->process->string, $match_offset); + $match->unWrap(); + $this->assertEquals('@foo; color: orange; @baz', $match->string->__toString()); + + $match = new BalancedMatch(clone $this->process->string, $match_offset); + $match->replace('@boo;'); + $this->assertEquals('@foo; @boo; @baz', $match->string->__toString()); + } +} diff --git a/tests/unit/CssCrush/ColorTest.php b/tests/unit/CssCrush/ColorTest.php new file mode 100644 index 0000000..ddac560 --- /dev/null +++ b/tests/unit/CssCrush/ColorTest.php @@ -0,0 +1,81 @@ +assertEquals('#ffefd5', (string) $color); + + $color = new Color('#ccc'); + $this->assertEquals('#cccccc', (string) $color); + + $color = new Color('hsla(120,50%,50%,.8)'); + $this->assertEquals('rgba(64,191,64,0.8)', (string) $color); + } + + public function testAdjust() + { + $color = new Color('rgb(255,0,0)'); + $color->toHsl()->adjust([0, 0, 0, -20]); + $this->assertEquals('rgba(255,0,0,0.8)', (string) $color); + } + + public function testGetHsl() + { + $color = new Color('red'); + $this->assertEquals([0, 1, .5, 1], $color->getHsl()); + } + + public function testGetHex() + { + $color = new Color('red'); + $this->assertEquals('#ff0000', $color->getHex()); + } + + public function testGetRgb() + { + $color = new Color('red'); + $this->assertEquals([255, 0, 0, 1], $color->getRgb()); + } + + public function testGetComponent() + { + $color = new Color('red'); + $this->assertEquals(255, $color->getComponent(0)); + $this->assertEquals(255, $color->getComponent('red')); + $this->assertEquals(0, $color->getComponent('green')); + $this->assertEquals(0, $color->getComponent('blue')); + $this->assertEquals(1, $color->getComponent('alpha')); + } + + public function testSetComponent() + { + $color = new Color('red'); + $color->setComponent(0, 0); + $this->assertEquals(0, $color->getComponent(0)); + + $color = new Color('#000000'); + $color->setComponent(3, '1'); + $this->assertEquals('#000000', $color->__toString()); + $color->setComponent(3, .5); + $this->assertEquals('rgba(0,0,0,0.5)', $color->__toString()); + + $color->setComponent('red', 100); + $color->setComponent('green', 100); + $color->setComponent('blue', 100); + $color->setComponent('alpha', .1); + $this->assertEquals('rgba(100,100,100,0.1)', $color->__toString()); + } + + public function testColorSplit() + { + list($base_color, $opacity) = Color::colorSplit('red'); + $this->assertEquals('red', $base_color); + $this->assertEquals(1, $opacity); + } +} diff --git a/tests/unit/CssCrush/DeclarationTest.php b/tests/unit/CssCrush/DeclarationTest.php new file mode 100644 index 0000000..f8dbf1a --- /dev/null +++ b/tests/unit/CssCrush/DeclarationTest.php @@ -0,0 +1,52 @@ +process = bootstrap_process(['minify' => false]); + $this->rule = new Rule('.foo', '-fOo-BAR: math(10 + 10, px) !important'); + $this->declaration = new Declaration('-fOo-BAR', 'baz !important'); + } + + public function test__construct() + { + $this->assertTrue($this->declaration->important); + $this->assertTrue($this->declaration->valid); + + $this->assertEquals('bar', $this->declaration->canonicalProperty); + $this->assertEquals('-foo-bar', $this->declaration->property); + $this->assertEquals('foo', $this->declaration->vendor); + $this->assertEquals('baz', $this->declaration->value); + } + + public function test__toString() + { + $this->assertEquals('-foo-bar: baz !important', (string) $this->declaration); + } + + public function testProcess() + { + foreach ($this->rule->declarations as $declaration) { + $declaration->process($this->rule); + $this->assertEquals('20px', $declaration->value); + } + } + + public function testIndexFunctions() + { + $declaration = new Declaration('color', 'rgba(0,0,0,.5), calc(100px)'); + $declaration->indexFunctions(); + $this->assertEquals(['rgba' => true, 'calc' => true], $declaration->functions); + } +} diff --git a/tests/unit/CssCrush/EventEmitterTest.php b/tests/unit/CssCrush/EventEmitterTest.php new file mode 100644 index 0000000..658a63b --- /dev/null +++ b/tests/unit/CssCrush/EventEmitterTest.php @@ -0,0 +1,32 @@ +on('foo', function ($data) use (&$foo) { + $foo = $data; + }); + + $this->assertEquals($foo, null); + + $emitter->emit('foo', 10); + + $this->assertEquals($foo, 10); + + $cancelEvent(); + + $emitter->emit('foo', 20); + + $this->assertEquals($foo, 10); + } +} + +class EventEmitterHost { use EventEmitter; } diff --git a/tests/unit/CssCrush/ExtendArgTest.php b/tests/unit/CssCrush/ExtendArgTest.php new file mode 100644 index 0000000..3145e03 --- /dev/null +++ b/tests/unit/CssCrush/ExtendArgTest.php @@ -0,0 +1,15 @@ +assertEquals('.foo :hover', $extend_arg->name); + $this->assertEquals(':hover', $extend_arg->pseudo); + } +} diff --git a/tests/unit/CssCrush/FunctionsTest.php b/tests/unit/CssCrush/FunctionsTest.php new file mode 100644 index 0000000..6cff8e0 --- /dev/null +++ b/tests/unit/CssCrush/FunctionsTest.php @@ -0,0 +1,24 @@ +assertEquals('~(?foo|bar)\(~iS', $patt); + + $hashChar = version_compare(PHP_VERSION, '7.3.0') >= 0 + ? '\\#' + : '#'; + + $patt = Functions::makePattern(['foo', 'bar', '#']); + $this->assertEquals('~(?:(?foo|bar)|(?' . $hashChar . '))\(~iS', $patt); + + $patt = Functions::makePattern(['$', '#']); + $this->assertEquals('~(?\$|' . $hashChar . ')\(~iS', $patt); + } +} diff --git a/tests/unit/CssCrush/LoggerTest.php b/tests/unit/CssCrush/LoggerTest.php new file mode 100644 index 0000000..531f7d2 --- /dev/null +++ b/tests/unit/CssCrush/LoggerTest.php @@ -0,0 +1,18 @@ +assertTrue($logger instanceof LoggerInterface); + } +} diff --git a/tests/unit/CssCrush/OptionsTest.php b/tests/unit/CssCrush/OptionsTest.php new file mode 100644 index 0000000..b140f78 --- /dev/null +++ b/tests/unit/CssCrush/OptionsTest.php @@ -0,0 +1,96 @@ +testFile = temp_file("\n foo {bar: baz;} \n\n baz {bar: foo;}"); + } + + public function testDefaults() + { + $options = new Options(); + $standardOptions = Options::filter(); + + $this->assertEquals($standardOptions, $options->get()); + + $testOptions = ['plugins' => ['foo', 'bar'], 'minify' => false]; + $options = new Options($testOptions); + + $initialOptionsCopy = $testOptions + $standardOptions; + $this->assertEquals($initialOptionsCopy, $options->get()); + } + + public function testBoilerplate() + { + $boilerplate = << temp_file($boilerplate), + 'newlines' => 'unix', + ]); + + $this->assertStringContainsStringIgnoringCase(' * ' . Version::detect(), (string) $result); + $this->assertStringContainsStringIgnoringCase(" * Line breaks\n * preserved\n *", (string) $result); + } + + public function testFormatters() + { + $sample = '/* A comment */ foo {bar: baz;}'; + + $single_line_expected = << 'single-line']); + $this->assertEquals($single_line_expected, $single_line); + + $padded_expected = << 'padded']); + $this->assertEquals($padded_expected, $padded); + + $block_expected = << 'block']); + $this->assertEquals($block_expected, $block); + } + + public function testSourceMaps() + { + csscrush_file($this->testFile, ['source_map' => true]); + $source_map_contents = file_get_contents("$this->testFile.crush.css.map"); + + $this->assertMatchesRegularExpression('~"version": ?3,~', $source_map_contents); + } + + public function testAdvancedMinify() + { + $sample = "foo { color: papayawhip; color: #cccccc;}"; + $output = csscrush_string($sample, ['minify' => ['colors']]); + + $this->assertEquals('foo{color:#ffefd5;color:#ccc}', $output); + } +} diff --git a/tests/unit/CssCrush/RegexTest.php b/tests/unit/CssCrush/RegexTest.php new file mode 100644 index 0000000..e656810 --- /dev/null +++ b/tests/unit/CssCrush/RegexTest.php @@ -0,0 +1,27 @@ +assertEquals('~(?\(\s*(?(?:(?>[^()]+)|(?&parens))*)\))~S', + Regex::make('~{{ parens }}~S')); + + $this->assertEquals('~ #[[:xdigit:]]{3} ~xS', Regex::make('~ #{{hex}}{3} ~xS')); + } + + public function testMatchAll() + { + $expected = [ + [['foo', 0]], + [['foo', 12]], + ]; + $matches = Regex::matchAll('~foo~', 'foo bar baz foo bar baz'); + + $this->assertEquals($expected, $matches); + } +} diff --git a/tests/unit/CssCrush/SelectorTest.php b/tests/unit/CssCrush/SelectorTest.php new file mode 100644 index 0000000..24b07cc --- /dev/null +++ b/tests/unit/CssCrush/SelectorTest.php @@ -0,0 +1,48 @@ +process = bootstrap_process(); + } + + public function testMakeReadable() + { + $sample = '#foo+bar [data="baz"]~ p:first-child .foo >bar::after'; + $sample = $this->process->tokens->capture($sample, 's'); + + $this->assertEquals('#foo + bar [data="baz"] ~ p:first-child .foo > bar::after', + Selector::makeReadable($sample)); + } + + public function testNormalizeWhiteSpace() + { + $sample = "#foo+bar [data=baz ]~ p:first-child .foo\n\n\t >bar::after"; + + $this->assertEquals('#foo + bar [data=baz] ~ p:first-child .foo > bar::after', + Selector::normalizeWhiteSpace($sample)); + } + + public function testAppendPseudo() + { + $test = new Selector('.foo'); + $test->appendPseudo(':hover'); + + $this->assertEquals('.foo:hover', $test->__toString()); + } + + public function testToString() + { + $this->process->minifyOutput = true; + $test = new Selector('.foo > .bar + .baz'); + + $this->assertEquals('.foo>.bar+.baz', $test->__toString()); + } +} diff --git a/tests/unit/CssCrush/StringObjectTest.php b/tests/unit/CssCrush/StringObjectTest.php new file mode 100644 index 0000000..55b1acc --- /dev/null +++ b/tests/unit/CssCrush/StringObjectTest.php @@ -0,0 +1,73 @@ +sample); + $this->assertEquals($this->sample, (string) $string); + } + + public function testEndsWith() + { + $this->assertTrue(StringObject::endsWith('amet', 'et')); + } + + public function testUpdate() + { + $string = new StringObject($this->sample); + $updated_text = 'foo'; + $string->update($updated_text); + $this->assertEquals($updated_text, (string) $string); + } + + public function testTrim() + { + $string = new StringObject($this->sample); + $this->assertEquals(trim($this->sample), (string) $string->trim()); + } + + public function testRTrim() + { + $string = new StringObject($this->sample); + $this->assertEquals(rtrim($this->sample), (string) $string->rTrim()); + } + + public function testLTrim() + { + $string = new StringObject($this->sample); + $this->assertEquals(ltrim($this->sample), (string) $string->lTrim()); + } + + public function testAppend() + { + $string = new StringObject($this->sample); + $append_text = 'foo'; + $this->assertEquals($this->sample . $append_text, (string) $string->append($append_text)); + } + + public function testPrepend() + { + $string = new StringObject($this->sample); + $prepend_text = 'foo'; + $this->assertEquals($prepend_text . $this->sample, (string) $string->prepend($prepend_text)); + } + + public function testSubstr() + { + $string = new StringObject($this->sample); + $this->assertEquals(substr($this->sample, 1), (string) $string->substr(1)); + } +} + +// matchAll +// replaceHash +// pregReplaceCallback +// pregReplaceHash +// splice diff --git a/tests/unit/CssCrush/TemplateTest.php b/tests/unit/CssCrush/TemplateTest.php new file mode 100644 index 0000000..9587f2e --- /dev/null +++ b/tests/unit/CssCrush/TemplateTest.php @@ -0,0 +1,83 @@ +template_raw = <<template_string = <<template = new Template($this->template_raw); + } + + public function test__construct() + { + $this->assertEquals($this->template_string, $this->template->string); + } + + public function testGetArgValue() + { + $args = ['default']; + $this->assertEquals('100%', $this->template->getArgValue(0, $args)); + + $args = ['foo', 'bar']; + $this->assertEquals('bar', $this->template->getArgValue(1, $args)); + } + + public function testPrepare() + { + $this->template->prepare(['one', 'two']); + $this->assertEquals( + [['?a0?', '?a1?'], ['one', 'two']], + $this->template->substitutions); + } + + public function testApply() + { + $actual = $this->template->__invoke(['one', 'two']); + $expected = <<assertEquals($expected, $actual); + + $actual = $this->template->__invoke(['default', 'colanut']); + $expected = <<assertEquals($expected, $actual); + } + + public function testTokenize() + { + $original_sample = <<assertStringContainsStringIgnoringCase('[foo=?s', $sample); + $this->assertStringContainsStringIgnoringCase('{baz: ?u', $sample); + + $sample = Template::unTokenize($sample); + $this->assertEquals($original_sample, $sample); + } +} diff --git a/tests/unit/CssCrush/TokensTest.php b/tests/unit/CssCrush/TokensTest.php new file mode 100644 index 0000000..72a1ebc --- /dev/null +++ b/tests/unit/CssCrush/TokensTest.php @@ -0,0 +1,122 @@ +process = bootstrap_process(['minify' => false]); + + $this->tokens = $this->process->tokens; + $this->tokens->add('"foo"', 's'); + $this->tokens->add('"bar"', 's'); + $this->tokens->add('"baz"', 's'); + } + + public function test__construct() + { + $this->assertEmpty( + array_diff_key( + array_flip(['s', 'c', 'r', 'u', 't']), + (array) $this->tokens->store + ) + ); + } + + public function testCreateLabel() + { + $type = 's'; + $this->assertMatchesRegularExpression("~^\?{$type}[a-z0-9]+\?$~", $this->tokens->createLabel($type)); + } + + public function testAdd() + { + $this->tokens->add('/*monkey*/', 'c'); + $this->assertContains('/*monkey*/', array_values($this->tokens->store->c)); + } + + public function testGet() + { + $value = reset($this->tokens->store->s); + $key = key($this->tokens->store->s); + $this->assertEquals($value, $this->tokens->get($key)); + } + + public function testRelease() + { + $label = $this->tokens->add('"foo"', 's'); + $this->assertTrue(isset($this->tokens->store->s[$label])); + + $this->tokens->pop($label); + $this->assertFalse(isset($this->tokens->store->s[$label])); + } + + public function testPop() + { + $label = $this->tokens->add('"foo"', 's'); + + $this->assertEquals('"foo"', $this->tokens->pop($label)); + $this->assertFalse(isset($this->tokens->store->s[$label])); + } + + public function testCapture() + { + $sample = '[class="foo"] {bar: url(http://github.com/baz.png);}'; + + $sample = $this->tokens->capture($sample, 'u'); + $this->assertStringContainsStringIgnoringCase('?u', $sample); + + $sample = $this->tokens->capture($sample, 's'); + $this->assertStringContainsStringIgnoringCase('?s', $sample); + } + + public function testCaptureUrls() + { + $sample = '[class="foo"] {bar: url(http://github.com/baz.png);}'; + + $sample = $this->tokens->captureUrls($sample); + $this->assertStringContainsStringIgnoringCase('?u', $sample); + } + + public function testRestore() + { + $sample = '[class="foo"] {bar: url(http://github.com/baz.png);}'; + + $modified = $this->tokens->captureUrls($sample); + $this->assertStringContainsStringIgnoringCase('?u', $modified); + + $modified = $this->tokens->restore($modified, 'u'); + $this->assertEquals($sample, $modified); + + $modified = $this->tokens->capture($sample, 's'); + $this->assertStringContainsStringIgnoringCase('?s', $modified); + + $modified = $this->tokens->restore($modified, 's'); + $this->assertEquals($sample, $modified); + } + + public function testPad() + { + $label = $this->tokens->createLabel('s'); + $padded_label = Tokens::pad($label, "\n lorem \n ipsum \n123"); + $this->assertEquals("$label\n\n\n ", $padded_label); + } + + public function testIs() + { + $this->assertTrue(Tokens::is($this->tokens->createLabel('s'), 's')); + } + + public function testTest() + { + $this->assertFalse(Tokens::test('foobar')); + $this->assertEquals(Tokens::test($this->tokens->createLabel('u')), 'u'); + $this->assertEquals(Tokens::test($this->tokens->createLabel('s')), 's'); + } +} diff --git a/tests/unit/CssCrush/UrlTest.php b/tests/unit/CssCrush/UrlTest.php new file mode 100644 index 0000000..62f81c1 --- /dev/null +++ b/tests/unit/CssCrush/UrlTest.php @@ -0,0 +1,139 @@ + false]); + } + + public function testConstruct() + { + $url = new Url('http://www.public.com/'); + $this->assertEquals('http', $url->protocol); + $this->assertTrue($url->isAbsolute); + $this->assertFalse($url->isRelative); + $this->assertFalse($url->isRooted); + $this->assertFalse($url->isData); + + $url = new Url('//www.public.com'); + $this->assertEquals('relative', $url->protocol); + $this->assertTrue($url->isAbsolute); + $this->assertFalse($url->isRelative); + $this->assertFalse($url->isRooted); + $this->assertFalse($url->isData); + + $url = new Url('local/resource.png'); + $this->assertNull($url->protocol); + $this->assertFalse($url->isAbsolute); + $this->assertTrue($url->isRelative); + $this->assertFalse($url->isRooted); + $this->assertFalse($url->isData); + + $url = new Url('/local/resource.png'); + $this->assertNull($url->protocol); + $this->assertFalse($url->isAbsolute); + $this->assertFalse($url->isRelative); + $this->assertTrue($url->isRooted); + $this->assertFalse($url->isData); + + $url = new Url('data:text/html'); + $this->assertEquals('data', $url->protocol); + $this->assertFalse($url->isAbsolute); + $this->assertFalse($url->isRelative); + $this->assertFalse($url->isRooted); + $this->assertTrue($url->isData); + } + + public function testToString() + { + $url = new Url('resource/with(parens)'); + $this->assertEquals('url("http://github.com/resource/with(parens)")', (string) $url); + + $url = new Url('simple/url'); + $this->assertEquals('url(http://github.com/simple/url)', (string) $url); + } + + public function testUpdate() + { + $url = new Url('simple/url'); + $update_url = 'different/url'; + $url->update($update_url); + $this->assertEquals($update_url, $url->value); + } + + public function testGetAbsolutePath() + { + $url_raw = 'simple/url'; + $url = new Url($url_raw); + $this->assertEquals(Crush::$process->docRoot . "/$url_raw", $url->getAbsolutePath()); + } + + public function testPrepend() + { + $url = new Url('simple/url'); + $this->assertEquals('../simple/url', $url->prepend('../')->value); + } + + public function testToRoot() + { + $url = new Url('simple/url'); + $this->assertEquals(Crush::$process->input->dirUrl . '/simple/url', $url->toRoot()->value); + } + + public function testToData() + { + $test_filename = str_replace('\\', '_', __CLASS__) . '.svg'; + $test_fileurl = "/tests/unit/$test_filename"; + $test_filepath = Crush::$process->docRoot . $test_fileurl; + + if (is_writable(dirname($test_filepath))) { + $svg = ''; + file_put_contents($test_filepath, $svg); + $url = new Url($test_fileurl); + $url->toData(); + unlink($test_filepath); + + $this->assertEquals( + 'data:image/svg+xml;utf8,', + $url->value); + } + else { + $this->markTestSkipped('Cannot write test SVG file to disk.'); + } + + $url = new Url('/tests/unit/dummy-data/tiny.png'); + $url->toData(); + $this->assertStringStartsWith( + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAASoAAAEqCAMAAACV5O0dAAAA', + $url->value); + } + + public function testSetType() + { + $url = new Url('simple/url'); + $url->setType('absolute'); + + $this->assertTrue($url->isAbsolute); + $this->assertFalse($url->isRelative); + $this->assertFalse($url->isRooted); + $this->assertFalse($url->isData); + } + + public function testSimplify() + { + $url = new Url("/some/../path/../something.css"); + $this->assertEquals('/something.css', $url->simplify()->value); + + $url = new Url("/some/../..//..\path/../something.css"); + $this->assertEquals('/../../something.css', $url->simplify()->value); + + $url = new Url("../../../blah/../../something.css"); + $this->assertEquals('../../../../something.css', $url->simplify()->value); + } +} diff --git a/tests/unit/CssCrush/UtilTest.php b/tests/unit/CssCrush/UtilTest.php new file mode 100644 index 0000000..2f72f82 --- /dev/null +++ b/tests/unit/CssCrush/UtilTest.php @@ -0,0 +1,138 @@ +process = bootstrap_process(['minify' => false]); + $this->tokens = $this->process->tokens; + } + + public function testNormalizePath() + { + $this->assertEquals('/Some/crazy/Path', Util::normalizePath('C:\\Some\crazy/Path\\', true)); + $this->assertEquals('/Some/crazy/Path', Util::normalizePath('/\Some//./crazy\\\/Path/')); + $this->assertEquals('sane/path', Util::normalizePath('./sane/path/')); + } + + public function testHtmlAttributes() + { + $attributes = [ + 'rel' => 'stylesheet', + 'id' => 'foo', + 'media' => 'screen', + ]; + + $this->assertEquals( + ' rel="stylesheet" id="foo" media="screen"', + Util::htmlAttributes($attributes)); + $this->assertEquals( + ' id="foo" media="screen" rel="stylesheet"', + Util::htmlAttributes($attributes, ['id', 'media', 'rel'])); + } + + public function testSimplifyPath() + { + $this->assertEquals('bar', Util::simplifyPath('foo/../bar')); + $this->assertEquals('./../', Util::simplifyPath('./foo/../bar/../../')); + } + + public function testVlqEncode() + { + $this->assertEquals('A', Util::vlqEncode(0)); + $this->assertEquals('C', Util::vlqEncode(1)); + $this->assertEquals('gB', Util::vlqEncode(16)); + $this->assertEquals('6H', Util::vlqEncode(125)); + $this->assertEquals('qmC', Util::vlqEncode(1125)); + } + + public function testStripCommentTokens() + { + $this->assertEquals('', Util::stripCommentTokens('?ca??cb?')); + } + + public function testResolveUserPath() + { + $this->assertEquals(__FILE__, Util::resolveUserPath(__FILE__)); + $this->assertFalse(Util::resolveUserPath(__FILE__ . 'nothing')); + + // Relative path resolution. + $original_path = getcwd(); + chdir(__DIR__); + $this_filename = basename(__FILE__); + // Case-insensitive file systems may normalize case. + $this->assertEquals(strtolower(__FILE__), strtolower(Util::resolveUserPath($this_filename))); + chdir($original_path); + } + + public function testNormalizeWhiteSpace() + { + $this->assertEquals( + '.foo[class]{rgb(0,0,0);}', + Util::normalizeWhiteSpace(".foo[ class ] { \t rgb( \t0\n , 0, 0\r\n ) ; } ")); + } + + public function testSplitDelimList() + { + $this->assertEquals(['foo(1,2)','3','4'], Util::splitDelimList("foo(1,2), 3,4")); + $this->assertEquals([], Util::splitDelimList(" ; ; ", ['delim' => ';'])); + $this->assertEquals(['', ''], Util::splitDelimList(" , ", ['allow_empty_strings' => true])); + } + + public function testGetLinkBetweenPaths() + { + $path1 = __DIR__; + $path2 = realpath(__DIR__ . '/../../'); + $this->assertEquals('../../', Util::getLinkBetweenPaths($path1, $path2)); + $this->assertEqualsIgnoringCase('Unit/CssCrush/', Util::getLinkBetweenPaths($path2, $path1)); + } + + public function testFilePutContents() + { + $test_file = sys_get_temp_dir() . '/' . str_replace('\\', '_', __CLASS__); + $this->assertTrue(Util::filePutContents($test_file, 'Hello Mum')); + } + + public function testRawValue() + { + $url1 = $this->tokens->add(new Url('foo.jpg')); + $url2 = $this->tokens->add(new Url('foo.jpg')); + $this->assertNotEquals($url1, $url2); + $this->assertEquals(Util::rawValue($url1), Util::rawValue($url2)); + $this->assertEquals(Util::rawValue($url1), 'foo.jpg'); + + $string1 = $this->tokens->add('"bar"', 's'); + $string2 = $this->tokens->add('"bar"', 's'); + $this->assertNotEquals($string1, $string2); + $this->assertEquals(Util::rawValue($string1), Util::rawValue($string2)); + $this->assertEquals(Util::rawValue($string1), '"bar"'); + + $this->assertEquals(Util::rawValue('foobar'), 'foobar'); + $this->assertNotEquals(Util::rawValue('foobar'), 'notFoobar'); + } + + public function testReadConfigFile() + { + $contents = <<<'NOW_DOC' +assertArrayHasKey('plugins', $options); + $this->assertArrayNotHasKey('unrecognised_option', $options); + } +} diff --git a/tests/unit/CssCrush/VersionTest.php b/tests/unit/CssCrush/VersionTest.php new file mode 100644 index 0000000..aec0fae --- /dev/null +++ b/tests/unit/CssCrush/VersionTest.php @@ -0,0 +1,53 @@ +minor = 9; + $version->extra = 'beta'; + $this->assertEquals('v2.9.5-beta', (string) $version); + + unset($version->extra); + $this->assertEquals('v2.9.5', (string) $version); + } + + public function testCompare() + { + $version = new Version('1.8.5-beta'); + $this->assertEquals(1, $version->compare('1')); + $this->assertEquals(-1, $version->compare('2')); + $this->assertEquals(0, $version->compare('1.8.5')); + } + + public function testProperties() + { + $version = new Version('1.8.5'); + $this->assertEquals(1, $version->major); + $this->assertEquals(8, $version->minor); + $this->assertEquals(5, $version->patch); + } + + public function testGitDescribe() + { + if ($version = Version::gitDescribe()) { + $this->assertMatchesRegularExpression('~^ + v + \d+\. + \d+\. + \d+ + (-(?:alpha|beta)\.\d+)? + -\d+ + -g.+ + $~x', $version->__toString()); + } + else { + $this->markTestSkipped('Returned null'); + } + } +} diff --git a/tests/unit/api/apiTest.php b/tests/unit/api/apiTest.php new file mode 100644 index 0000000..56b04b1 --- /dev/null +++ b/tests/unit/api/apiTest.php @@ -0,0 +1,120 @@ +sample = ".foo {bar: baz;}"; + $this->sampleExpected = ".foo{bar:baz}"; + $this->sampleFile = temp_file($this->sample); + $this->originalWd = getcwd(); + chdir(dirname($this->sampleFile)); + } + + public function tearDown(): void + { + chdir($this->originalWd); + } + + public function testString() + { + $this->assertEquals($this->sampleExpected, csscrush_string($this->sample)); + } + + public function testInline() + { + $this->assertEquals("\n", csscrush_inline($this->sampleFile)); + $this->assertEquals("\n", csscrush_inline($this->sampleFile, null, [ + 'type' => 'text/css', + 'id' => 'foo', + ])); + } + + public function testFile() + { + $test_dir = dirname($this->sampleFile); + $test_file = csscrush_file($this->sampleFile, [ + 'versioning' => false, + 'cache' => false, + 'doc_root' => $test_dir, + 'boilerplate' => false, + ]); + $filepath = "$test_dir$test_file"; + + $this->assertEquals($this->sampleExpected, file_get_contents($filepath)); + } + + public function testTag() + { + $test_dir = dirname($this->sampleFile); + $base_options = [ + 'versioning' => false, + 'cache' => false, + 'doc_root' => $test_dir, + 'boilerplate' => false, + ]; + + $url = '/' . basename($this->sampleFile) . '.crush.css'; + $test_tag = csscrush_tag($this->sampleFile, $base_options); + + $this->assertEquals("\n", $test_tag); + + $test_tag = csscrush_tag($this->sampleFile, $base_options, ['media' => 'print', 'id' => 'foo']); + + $this->assertEquals("\n", $test_tag); + } + + public function testStat() + { + $sample = << false, + ]); + + $stats = csscrush_stat(); + + $this->assertEquals(6, $stats['selector_count']); + $this->assertEquals(2, $stats['rule_count']); + $this->assertArrayHasKey('timestamp', $stats['vars']); + unset($stats['vars']['timestamp']); + $this->assertEquals([], $stats['vars']); + $this->assertEquals([], $stats['vars']); + $this->assertEquals([], $stats['errors']); + $this->assertTrue(isset($stats['compile_time'])); + } + + public function testGetSet() + { + csscrush_set('config', function ($config) { + $config->foo = 'bar'; + }); + $this->assertEquals('bar', csscrush_get('config', 'foo')); + + csscrush_set('config', [ + 'hello' => 'world', + ]); + $this->assertEquals('world', csscrush_get('config', 'hello')); + + $this->assertInstanceOf('stdClass', csscrush_get('config')); + + $this->assertInstanceOf('CssCrush\Options', csscrush_get('options')); + + csscrush_set('options', ['enable' => 'property-sorter']); + + $this->assertStringContainsStringIgnoringCase('property-sorter', csscrush_get('options', 'enable')); + + csscrush_set('options', ['enable' => []]); + } +} diff --git a/tests/unit/cli/cliTest.php b/tests/unit/cli/cliTest.php new file mode 100644 index 0000000..694f307 --- /dev/null +++ b/tests/unit/cli/cliTest.php @@ -0,0 +1,79 @@ +path = Crush::$dir . '/cli.php'; + $this->sample = 'p {color: red; position: absolute; opacity: 1;}'; + } + + public function testHelp() + { + exec("php \"$this->path\"", $lines); + $help_text = implode("\n", $lines); + + $this->assertStringContainsStringIgnoringCase('USAGE:', $help_text); + } + + public function testPlugin() + { + exec("echo '$this->sample' | php \"$this->path\" --enable property-sorter", $lines); + $expected = 'p{position:absolute;opacity:1;color:red}'; + + $this->assertEquals($expected, implode('',$lines)); + } + + public function testIO() + { + $in_path = temp_file(); + $out_path = temp_file() . '.css'; + + file_put_contents($in_path, $this->sample); + exec("php \"$this->path\" -i '$in_path' -o '$out_path' --enable property-sorter --test"); + $expected = 'p{position:absolute;opacity:1;color:red}'; + + $this->assertStringContainsStringIgnoringCase($expected, file_get_contents($out_path)); + } + + public function testStats() + { + exec("echo '$this->sample' | php \"$this->path\" --stats --test", $lines); + $output = implode('', $lines); + + $this->assertStringContainsStringIgnoringCase('Selector count: 1', $output); + $this->assertStringContainsStringIgnoringCase('Rule count: 1', $output); + $this->assertStringContainsStringIgnoringCase('Compile time:', $output); + $this->assertStringContainsStringIgnoringCase('p{color:red;position:absolute;opacity:1}', $output); + } + + public function testContext() + { + $sample = '@import "http://github.com/context/import.css"; baz {bar: foo;}'; + $context = __DIR__; + + exec("echo '$sample' | php \"$this->path\" --context '$context'", $lines); + + $this->assertEquals('foo{bar:baz}baz{bar:foo}', implode('', $lines)); + } + + public function testConfigFile() + { + $currentDirectory = getcwd(); + chdir(__DIR__ . '/context'); + + $sample = '@import "http://github.com/import.css"; baz {color: #111;}'; + exec("echo '$sample' | php \"$this->path\"", $lines); + + $this->assertEquals('foo{bar:baz}baz{color:#111}', implode('', $lines)); + + chdir($currentDirectory); + } +} diff --git a/tests/unit/cli/context/crushfile.php b/tests/unit/cli/context/crushfile.php new file mode 100644 index 0000000..3e4e005 --- /dev/null +++ b/tests/unit/cli/context/crushfile.php @@ -0,0 +1,5 @@ +