diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9c3926dc..fa918f66 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -571,6 +571,94 @@ jobs: DEFAULT_CROSS_BUILD_ENV_URL: "https://github.com/pyodide/pyodide/releases/download/0.28.0a3/xbuildenv-0.28.0a3.tar.bz2" RUSTFLAGS: "-C link-arg=-sSIDE_MODULE=2 -Z link-native-libraries=no -Z emscripten-wasm-eh" + test-php: + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, macos-15] + php-version: ["8.2", "8.3", "8.4"] + clang: ["20"] + + name: PHP ${{ matrix.php-version }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + + - uses: dtolnay/rust-toolchain@stable + + - name: Cache LLVM and Clang + id: cache-llvm + uses: actions/cache@v4 + if: matrix.os == 'ubuntu-22.04' + with: + path: ${{ runner.temp }}/llvm-${{ matrix.clang }} + key: ${{ matrix.os }}-llvm-${{ matrix.clang }} + + - name: Setup LLVM & Clang + id: clang + uses: KyleMayes/install-llvm-action@v2 + if: matrix.os == 'ubuntu-22.04' + with: + version: ${{ matrix.clang }} + directory: ${{ runner.temp }}/llvm-${{ matrix.clang }} + cached: ${{ steps.cache-llvm.outputs.cache-hit }} + + - name: Configure Clang + if: matrix.os == 'ubuntu-22.04' + run: | + echo "LIBCLANG_PATH=${{ runner.temp }}/llvm-${{ matrix.clang }}/lib" >> $GITHUB_ENV + echo "LLVM_VERSION=${{ steps.clang.outputs.version }}" >> $GITHUB_ENV + echo "LLVM_CONFIG_PATH=${{ runner.temp }}/llvm-${{ matrix.clang }}/bin/llvm-config" >> $GITHUB_ENV + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: mbstring + coverage: none + + - name: Build PHP extension + run: | + export PHP_CONFIG=$(which php-config) + + cargo build --release + + EXT_DIR=$(php -r "echo ini_get('extension_dir');") + + if [[ "${{ matrix.os }}" == "macos-15" ]]; then + BUILT_LIB=$(find target/release -name "libcss_inline_php.dylib" -o -name "css_inline_php.dylib" | head -1) + if [[ -z "$BUILT_LIB" ]]; then + BUILT_LIB=$(find target/release -name "*.dylib" | head -1) + fi + sudo cp "$BUILT_LIB" "$EXT_DIR/css_inline.so" + else + BUILT_LIB=$(find target/release -name "*.so" | head -1) + sudo cp "$BUILT_LIB" "$EXT_DIR/css_inline.so" + fi + working-directory: ./bindings/php + shell: bash + + - name: Enable and verify extension + run: | + if [[ "${{ matrix.os }}" == "macos-15" ]]; then + PHP_INI_DIR=$(php -i | grep "Scan this dir for additional .ini files" | cut -d' ' -f9 | tr -d ' ') + echo "extension=css_inline" | sudo tee "$PHP_INI_DIR/99-css_inline.ini" + else + echo "extension=css_inline" | sudo tee /etc/php/${{ matrix.php-version }}/cli/conf.d/99-css_inline.ini + fi + shell: bash + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + working-directory: ./bindings/php + + - name: Lint PHP code + run: composer lint + working-directory: ./bindings/php + + - name: Run tests + run: composer test + working-directory: ./bindings/php + test-ruby: strategy: fail-fast: false diff --git a/.github/workflows/php-release.yml b/.github/workflows/php-release.yml new file mode 100644 index 00000000..f5db1f60 --- /dev/null +++ b/.github/workflows/php-release.yml @@ -0,0 +1,125 @@ +name: "[PHP] Release" + +on: + push: + tags: + - php-v* + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + php: "8.2" + artifact: libcss_inline_php.so + - os: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + php: "8.3" + artifact: libcss_inline_php.so + - os: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + php: "8.4" + artifact: libcss_inline_php.so + - os: macos-15 + target: x86_64-apple-darwin + php: "8.2" + artifact: libcss_inline_php.dylib + cross: true + - os: macos-15 + target: x86_64-apple-darwin + php: "8.3" + artifact: libcss_inline_php.dylib + cross: true + - os: macos-15 + target: x86_64-apple-darwin + php: "8.4" + artifact: libcss_inline_php.dylib + cross: true + - os: macos-15 + target: aarch64-apple-darwin + php: "8.2" + artifact: libcss_inline_php.dylib + - os: macos-15 + target: aarch64-apple-darwin + php: "8.3" + artifact: libcss_inline_php.dylib + - os: macos-15 + target: aarch64-apple-darwin + php: "8.4" + artifact: libcss_inline_php.dylib + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v6 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.cross && matrix.target || '' }} + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: bindings/php + cache-all-crates: "true" + + - name: Build + run: | + if [ "${{ matrix.cross }}" = "true" ]; then + cargo build --release --target ${{ matrix.target }} + else + cargo build --release + fi + working-directory: bindings/php + + - name: Rename artifact + run: | + mkdir -p dist + if [ "${{ matrix.cross }}" = "true" ]; then + cp bindings/php/target/${{ matrix.target }}/release/${{ matrix.artifact }} dist/css_inline-php${{ matrix.php }}-${{ matrix.target }}.so + else + cp bindings/php/target/release/${{ matrix.artifact }} dist/css_inline-php${{ matrix.php }}-${{ matrix.target }}.so + fi + + - name: Upload artifact + uses: actions/upload-artifact@v6 + with: + name: css_inline-php${{ matrix.php }}-${{ matrix.target }} + path: dist/ + + release: + needs: build + runs-on: ubuntu-22.04 + permissions: + contents: write + + steps: + - uses: actions/checkout@v6 + + - name: Extract Version + run: echo "version=${GITHUB_REF#refs/tags/php-v}" >> $GITHUB_ENV + + - name: Download all artifacts + uses: actions/download-artifact@v7 + with: + path: artifacts + merge-multiple: true + + - name: List artifacts + run: ls -la artifacts/ + + - name: GitHub Release + uses: softprops/action-gh-release@v2 + with: + make_latest: false + draft: true + name: "[PHP] Release ${{ env.version }}" + files: artifacts/* diff --git a/README.md b/README.md index b69b7462..683a27b8 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ into: - Optionally caches external stylesheets - Works on Linux, Windows, and macOS - Supports HTML5 & CSS3 -- Bindings for [Python](https://github.com/Stranger6667/css-inline/tree/master/bindings/python), [Ruby](https://github.com/Stranger6667/css-inline/tree/master/bindings/ruby), [JavaScript](https://github.com/Stranger6667/css-inline/tree/master/bindings/javascript), [Java](https://github.com/Stranger6667/css-inline/tree/master/bindings/java), [C](https://github.com/Stranger6667/css-inline/tree/master/bindings/c), and a [WebAssembly](https://github.com/Stranger6667/css-inline/tree/master/bindings/javascript/wasm) module to run in browsers. +- Bindings for [Python](https://github.com/Stranger6667/css-inline/tree/master/bindings/python), [Ruby](https://github.com/Stranger6667/css-inline/tree/master/bindings/ruby), [JavaScript](https://github.com/Stranger6667/css-inline/tree/master/bindings/javascript), [Java](https://github.com/Stranger6667/css-inline/tree/master/bindings/java), [C](https://github.com/Stranger6667/css-inline/tree/master/bindings/c), [PHP](https://github.com/Stranger6667/css-inline/tree/master/bindings/php), and a [WebAssembly](https://github.com/Stranger6667/css-inline/tree/master/bindings/javascript/wasm) module to run in browsers. - Command Line Interface ## Playground diff --git a/bindings/javascript/wasm/index.html b/bindings/javascript/wasm/index.html index 4a23bb83..600874b8 100644 --- a/bindings/javascript/wasm/index.html +++ b/bindings/javascript/wasm/index.html @@ -9,7 +9,7 @@ /> CSS Inline | High-performance CSS inlining

css-inline uses components from Mozilla's Servo project and provides - bindings for Rust, Python, Ruby, JavaScript, Java, and C. The + bindings for Rust, Python, Ruby, JavaScript, Java, C, and PHP. The playground runs the library compiled to WebAssembly in the browser. Paste HTML with CSS into the text area and click "Inline" to process the output. diff --git a/bindings/php/.cargo/config.toml b/bindings/php/.cargo/config.toml new file mode 100644 index 00000000..d566dec3 --- /dev/null +++ b/bindings/php/.cargo/config.toml @@ -0,0 +1,12 @@ +[target.x86_64-unknown-linux-gnu] +rustflags = ["-C", "link-arg=-Wl,-undefined,dynamic_lookup"] + +[target.x86_64-apple-darwin] +rustflags = ["-C", "link-arg=-Wl,-undefined,dynamic_lookup"] + +[target.aarch64-apple-darwin] +rustflags = ["-C", "link-arg=-Wl,-undefined,dynamic_lookup"] + +[target.x86_64-pc-windows-msvc] +linker = "rust-lld" +rustflags = ["-C", "link-arg=/FORCE"] diff --git a/bindings/php/.gitignore b/bindings/php/.gitignore new file mode 100644 index 00000000..7993a8ef --- /dev/null +++ b/bindings/php/.gitignore @@ -0,0 +1,4 @@ +/vendor/ +/composer.lock +/.phpunit.cache/ +/.php-cs-fixer.cache diff --git a/bindings/php/.php-cs-fixer.dist.php b/bindings/php/.php-cs-fixer.dist.php new file mode 100644 index 00000000..00477833 --- /dev/null +++ b/bindings/php/.php-cs-fixer.dist.php @@ -0,0 +1,21 @@ +in(__DIR__ . '/tests') + ->in(__DIR__ . '/stubs') + ->in(__DIR__ . '/benchmarks') + ->name('*.php'); + +return (new PhpCsFixer\Config()) + ->setRiskyAllowed(true) + ->setRules([ + '@PER-CS' => true, + '@PHP82Migration' => true, + 'array_syntax' => ['syntax' => 'short'], + 'declare_strict_types' => true, + 'no_unused_imports' => true, + 'ordered_imports' => ['sort_algorithm' => 'alpha'], + 'single_quote' => true, + 'trailing_comma_in_multiline' => true, + ]) + ->setFinder($finder); diff --git a/bindings/php/Cargo.toml b/bindings/php/Cargo.toml new file mode 100644 index 00000000..e88065fb --- /dev/null +++ b/bindings/php/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "css_inline" +version = "0.18.0" +edition = "2021" +authors = ["Dmitry Dygalo "] + +[lib] +name = "css_inline_php" +crate-type = ["cdylib"] + +[dependencies] +ext-php-rs = "0.15.2" +rayon = "1" + +[dependencies.css-inline] +path = "../../css-inline" +version = "*" +default-features = false +features = ["http", "file", "stylesheet-cache"] diff --git a/bindings/php/README.md b/bindings/php/README.md new file mode 100644 index 00000000..9e8d86d8 --- /dev/null +++ b/bindings/php/README.md @@ -0,0 +1,299 @@ +# css_inline + +[build status](https://github.com/Stranger6667/css-inline/actions/workflows/build.yml) +[codecov.io](https://app.codecov.io/github/Stranger6667/css-inline) +[gitter](https://gitter.im/Stranger6667/css-inline) + +`css_inline` is a high-performance library for inlining CSS into HTML 'style' attributes. + +This library is designed for scenarios such as preparing HTML emails or embedding HTML into third-party web pages. + +For instance, the library transforms HTML like this: + +```html + + + + + +

Big Text

+ + +``` + +into: + +```html + + + +

Big Text

+ + +``` + +- Uses reliable components from Mozilla's Servo project +- 3-25x faster than alternatives +- Inlines CSS from `style` and `link` tags +- Removes `style` and `link` tags +- Resolves external stylesheets (including local files) +- Optionally caches external stylesheets +- Can process multiple documents in parallel +- Works on Linux and macOS (Windows is not supported) +- Supports HTML5 & CSS3 + +## Playground + +If you'd like to try `css-inline`, you can check the WebAssembly-powered [playground](https://css-inline.org/) to see the results instantly. + +## Installation + +`css_inline` is distributed as a PHP extension. You'll need to compile it from source: + +```shell +git clone https://github.com/Stranger6667/css-inline.git +cd css-inline/bindings/php +cargo build --release +``` + +Then copy the compiled extension to your PHP extensions directory: + +```shell +# Linux +cp target/release/libcss_inline_php.so $(php-config --extension-dir)/css_inline.so + +# macOS +cp target/release/libcss_inline_php.dylib $(php-config --extension-dir)/css_inline.so +``` + +Enable the extension in your `php.ini`: + +```ini +extension=css_inline +``` + +Requirements: +- PHP 8.2 or higher +- Rust toolchain (for building from source) +- Linux or macOS (Windows is not supported by the underlying `ext-php-rs` library) + +## Usage + +```php + + + + + +

Big Text

+ + +HTML; + +$inlined = CssInline\inline($html); +// HTML becomes: +// +// +// +//

Big Text

+// +// +``` + +Note that `css_inline` automatically adds missing `html` and `body` tags, so the output is a valid HTML document. + +Alternatively, you can inline CSS into an HTML fragment, preserving the original structure: + +```php + +

Hello

+
+

who am i

+
+ +HTML; + +$css = << +//

Hello

+//
+//

who am i

+//
+// +``` + +When there is a need to inline multiple HTML documents simultaneously, `css_inline` offers `inlineMany` and `inlineManyFragments` functions. +This feature allows for concurrent processing of several inputs, significantly improving performance when dealing with a large number of documents. + +```php +inline($html); +``` + +- `inlineStyleTags`. Specifies whether to inline CSS from "style" tags. Default: `true` +- `keepStyleTags`. Specifies whether to keep "style" tags after inlining. Default: `false` +- `keepLinkTags`. Specifies whether to keep "link" tags after inlining. Default: `false` +- `keepAtRules`. Specifies whether to keep "at-rules" (starting with `@`) after inlining. Default: `false` +- `minifyCss`. Specifies whether to remove trailing semicolons and spaces between properties and values. Default: `false` +- `baseUrl`. The base URL used to resolve relative URLs. If you'd like to load stylesheets from your filesystem, use the `file://` scheme. Default: `null` +- `loadRemoteStylesheets`. Specifies whether remote stylesheets should be loaded. Default: `true` +- `cache`. Specifies caching options for external stylesheets (for example, `new StylesheetCache(size: 5)`). Default: `null` +- `extraCss`. Extra CSS to be inlined. Default: `null` +- `preallocateNodeCapacity`. **Advanced**. Preallocates capacity for HTML nodes during parsing. This can improve performance when you have an estimate of the number of nodes in your HTML document. Default: `32` + +You can also skip CSS inlining for an HTML tag by adding the `data-css-inline="ignore"` attribute to it: + +```html + + + + + +

Big Text

+ +``` + +The `data-css-inline="ignore"` attribute also allows you to skip `link` and `style` tags: + +```html + + + + + +

Big Text

+ +``` + +Alternatively, you may keep `style` from being removed by using the `data-css-inline="keep"` attribute. +This is useful if you want to keep `@media` queries for responsive emails in separate `style` tags. +Such tags will be kept in the resulting HTML even if the `keepStyleTags` option is set to `false`. + +```html + + + + + +

Big Text

+ +``` + +Another possibility is to set `keepAtRules` option to `true`. At-rules cannot be inlined into HTML therefore they +get removed by default. This is useful if you want to keep at-rules, e.g. `@media` queries for responsive emails in +separate `style` tags but inline any styles which can be inlined. +Such tags will be kept in the resulting HTML even if the `keepStyleTags` option is explicitly set to `false`. + +```html + + + + + +

Big Text

+ +``` + +If you set the `minifyCss` option to `true`, the inlined styles will be minified by removing trailing semicolons +and spaces between properties and values. + +```html + + + + + +

Big Text

+ +``` + +If you'd like to load stylesheets from your filesystem, use the `file://` scheme: + +```php +inline($html); +``` + +You can also cache external stylesheets to avoid excessive network requests: + +```php +inline($html); +``` + +Caching is disabled by default. + +## Performance + +`css_inline` is powered by efficient tooling from Mozilla's Servo project and significantly outperforms other PHP alternatives in terms of speed. + +Here is the performance comparison: + +| | Size | `css_inline 0.18.0` | `css-to-inline-styles 2.3.0` | `emogrifier 7.3.0` | +|-------------------|---------|---------------------|------------------------------|------------------------| +| Simple | 230 B | 5.89 µs | 26.62 µs (**4.52x**) | 135.91 µs (**23.07x**) | +| Realistic email 1 | 8.58 KB | 103.23 µs | 285.73 µs (**2.77x**) | 597.33 µs (**5.79x**) | +| Realistic email 2 | 4.3 KB | 65.71 µs | 583.90 µs (**8.89x**) | 2.29 ms (**34.85x**) | +| GitHub Page† | 1.81 MB | 39.03 ms | ERROR | ERROR | + +† The GitHub page benchmark contains complex modern CSS that neither `css-to-inline-styles` nor `emogrifier` can process. + +Please refer to the `benchmarks/InlineBench.php` file to review the benchmark code. +The results displayed above were measured using stable `rustc 1.91` on PHP `8.4.14`. + +## Further reading + +If you want to know how this library was created & how it works internally, you could take a look at these articles: + +- [Rust crate](https://dygalo.dev/blog/rust-for-a-pythonista-2/) +- [Python bindings](https://dygalo.dev/blog/rust-for-a-pythonista-3/) + +## License + +This project is licensed under the terms of the [MIT license](https://opensource.org/licenses/MIT). diff --git a/bindings/php/benchmarks/InlineBench.php b/bindings/php/benchmarks/InlineBench.php new file mode 100644 index 00000000..971ca40d --- /dev/null +++ b/bindings/php/benchmarks/InlineBench.php @@ -0,0 +1,75 @@ +cssToInlineStyles = new CssToInlineStyles(); + ini_set('pcre.backtrack_limit', '10000000'); + ini_set('pcre.recursion_limit', '10000000'); + ini_set('memory_limit', '2048M'); + } + + /** + * @ParamProviders("provideAllCases") + */ + public function benchCssInline(array $params): void + { + \CssInline\inline($params['html']); + } + + /** + * @ParamProviders("provideSmallCases") + */ + public function benchCssToInlineStyles(array $params): void + { + $this->cssToInlineStyles->convert($params['html']); + } + + /** + * @ParamProviders("provideSmallCases") + */ + public function benchEmogrifier(array $params): void + { + CssInliner::fromHtml($params['html'])->inlineCss()->render(); + } + + public function provideAllCases(): \Generator + { + yield from $this->loadBenchmarks(skipLarge: false); + } + + public function provideSmallCases(): \Generator + { + yield from $this->loadBenchmarks(skipLarge: true); + } + + private function loadBenchmarks(bool $skipLarge): \Generator + { + $jsonPath = __DIR__ . '/../../../benchmarks/benchmarks.json'; + $json = file_get_contents($jsonPath); + $benchmarks = json_decode($json, true); + + foreach ($benchmarks as $benchmark) { + if ($skipLarge && in_array($benchmark['name'], self::SKIP_FOR_OTHER_LIBS, true)) { + continue; + } + yield $benchmark['name'] => [ + 'html' => $benchmark['html'], + ]; + } + } +} diff --git a/bindings/php/composer.json b/bindings/php/composer.json new file mode 100644 index 00000000..3cf8d01f --- /dev/null +++ b/bindings/php/composer.json @@ -0,0 +1,33 @@ +{ + "name": "css-inline/php", + "description": "High-performance library for inlining CSS into HTML 'style' attributes", + "type": "library", + "license": "MIT", + "require": { + "php": ">=8.2", + "ext-css_inline": "*" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.65", + "pelago/emogrifier": "^7.3", + "phpbench/phpbench": "^1.4", + "phpunit/phpunit": "^10.5", + "tijsverkoyen/css-to-inline-styles": "^2.3" + }, + "autoload-dev": { + "psr-4": { + "CssInline\\Tests\\": "tests/CssInlineTest" + } + }, + "scripts": { + "test": "phpunit", + "bench": "EXT=$(if [ \"$(uname)\" = \"Darwin\" ]; then echo 'dylib'; else echo 'so'; fi) && phpbench run --report=default --iterations=10 --revs=100 --php-config='{\"extension\": \"target/release/libcss_inline_php.'$EXT'\"}'", + "fmt": "php-cs-fixer fix", + "lint": "php-cs-fixer fix --dry-run --diff" + }, + "config": { + "sort-packages": true, + "optimize-autoloader": true, + "process-timeout": 0 + } +} diff --git a/bindings/php/phpbench.json b/bindings/php/phpbench.json new file mode 100644 index 00000000..31756982 --- /dev/null +++ b/bindings/php/phpbench.json @@ -0,0 +1,5 @@ +{ + "runner.bootstrap": "vendor/autoload.php", + "runner.path": "benchmarks", + "runner.timeout": 3600 +} diff --git a/bindings/php/phpunit.xml b/bindings/php/phpunit.xml new file mode 100644 index 00000000..4f1ea7fe --- /dev/null +++ b/bindings/php/phpunit.xml @@ -0,0 +1,11 @@ + + + + + tests + + + diff --git a/bindings/php/src/lib.rs b/bindings/php/src/lib.rs new file mode 100644 index 00000000..5dde225b --- /dev/null +++ b/bindings/php/src/lib.rs @@ -0,0 +1,183 @@ +#![allow(non_snake_case)] + +use std::{fmt::Display, num::NonZeroUsize, sync::Mutex}; + +use ext_php_rs::{exception::PhpException, prelude::*, zend::ce}; +use rayon::prelude::*; + +#[php_const] +#[php(name = "CssInline\\VERSION")] +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[php_class] +#[php(name = "CssInline\\InlineError")] +#[php(extends(ce = ce::exception, stub = "\\Exception"))] +#[derive(Default)] +pub struct InlineError; + +fn from_error(error: E) -> PhpException { + PhpException::from_class::(error.to_string()) +} + +#[php_class] +#[php(name = "CssInline\\StylesheetCache")] +pub struct StylesheetCache { + size: NonZeroUsize, +} + +#[php_impl] +impl StylesheetCache { + pub fn __construct(size: usize) -> PhpResult { + let size = NonZeroUsize::new(size).ok_or_else(|| { + PhpException::default("Cache size must be an integer greater than zero".to_string()) + })?; + Ok(StylesheetCache { size }) + } +} + +#[php_class] +#[php(name = "CssInline\\CssInliner")] +pub struct CssInliner { + inner: css_inline::CSSInliner<'static>, +} + +#[php_impl] +impl CssInliner { + #[php(defaults( + inlineStyleTags = true, + keepStyleTags = false, + keepLinkTags = false, + keepAtRules = false, + minifyCss = false, + loadRemoteStylesheets = true, + baseUrl = None, + extraCss = None, + preallocateNodeCapacity = 32, + cache = None, + ))] + #[php(optional = inlineStyleTags)] + #[allow(clippy::too_many_arguments)] + pub fn __construct( + inlineStyleTags: bool, + keepStyleTags: bool, + keepLinkTags: bool, + keepAtRules: bool, + minifyCss: bool, + loadRemoteStylesheets: bool, + baseUrl: Option, + extraCss: Option, + preallocateNodeCapacity: i64, + cache: Option<&StylesheetCache>, + ) -> PhpResult { + if preallocateNodeCapacity < 0 { + return Err(PhpException::default( + "preallocateNodeCapacity must be a non-negative integer".to_string(), + )); + } + + let base_url = if let Some(url) = baseUrl { + Some(css_inline::Url::parse(&url).map_err(from_error)?) + } else { + None + }; + + let stylesheet_cache = if let Some(cache) = cache { + Some(Mutex::new(css_inline::StylesheetCache::new(cache.size))) + } else { + None + }; + + #[allow(clippy::cast_sign_loss)] + let options = css_inline::InlineOptions { + inline_style_tags: inlineStyleTags, + keep_style_tags: keepStyleTags, + keep_link_tags: keepLinkTags, + keep_at_rules: keepAtRules, + minify_css: minifyCss, + base_url, + load_remote_stylesheets: loadRemoteStylesheets, + extra_css: extraCss.map(Into::into), + preallocate_node_capacity: preallocateNodeCapacity as usize, + cache: stylesheet_cache, + ..Default::default() + }; + + Ok(CssInliner { + inner: css_inline::CSSInliner::new(options), + }) + } + + pub fn inline(&self, html: &str) -> PhpResult { + self.inner.inline(html).map_err(from_error) + } + + #[php(name = "inlineFragment")] + pub fn inline_fragment(&self, html: &str, css: &str) -> PhpResult { + self.inner.inline_fragment(html, css).map_err(from_error) + } + + #[php(name = "inlineMany")] + pub fn inline_many(&self, htmls: Vec) -> PhpResult> { + htmls + .par_iter() + .map(|html| self.inner.inline(html)) + .collect::, _>>() + .map_err(from_error) + } + + #[php(name = "inlineManyFragments")] + pub fn inline_many_fragments(&self, htmls: Vec, css: &str) -> PhpResult> { + htmls + .par_iter() + .map(|html| self.inner.inline_fragment(html, css)) + .collect::, _>>() + .map_err(from_error) + } +} + +#[php_function] +#[php(name = "CssInline\\inline")] +pub fn inline(html: &str) -> PhpResult { + css_inline::inline(html).map_err(from_error) +} + +#[php_function] +#[php(name = "CssInline\\inlineFragment")] +pub fn inline_fragment(fragment: &str, css: &str) -> PhpResult { + css_inline::inline_fragment(fragment, css).map_err(from_error) +} + +#[php_function] +#[php(name = "CssInline\\inlineMany")] +pub fn inline_many(htmls: Vec) -> PhpResult> { + let inliner = css_inline::CSSInliner::default(); + htmls + .par_iter() + .map(|html| inliner.inline(html)) + .collect::, _>>() + .map_err(from_error) +} + +#[php_function] +#[php(name = "CssInline\\inlineManyFragments")] +pub fn inline_many_fragments(htmls: Vec, css: &str) -> PhpResult> { + let inliner = css_inline::CSSInliner::default(); + htmls + .par_iter() + .map(|html| inliner.inline_fragment(html, css)) + .collect::, _>>() + .map_err(from_error) +} + +#[php_module] +pub fn get_module(module: ModuleBuilder) -> ModuleBuilder { + module + .constant(wrap_constant!(VERSION)) + .class::() + .class::() + .class::() + .function(wrap_function!(inline)) + .function(wrap_function!(inline_fragment)) + .function(wrap_function!(inline_many)) + .function(wrap_function!(inline_many_fragments)) +} diff --git a/bindings/php/stubs/css_inline.php b/bindings/php/stubs/css_inline.php new file mode 100644 index 00000000..c700c511 --- /dev/null +++ b/bindings/php/stubs/css_inline.php @@ -0,0 +1,109 @@ +assertMatchesRegularExpression('/^\d+\.\d+\.\d+$/', \CssInline\VERSION); + } + + public function testBasicInline(): void + { + $html = '

Hello

'; + $result = CssInline\inline($html); + $this->assertStringContainsString('style="color: blue;"', $result); + $this->assertStringNotContainsString('

Hello

'; + $html2 = '

World

'; + $results = CssInline\inlineMany([$html1, $html2]); + + $this->assertCount(2, $results); + $this->assertStringContainsString('style="color: blue;"', $results[0]); + $this->assertStringContainsString('style="color: red;"', $results[1]); + } + + public function testInlineManyFragments(): void + { + $html1 = '

Hello

'; + $html2 = '

World

'; + $css = 'h1 { color: green; }'; + $results = CssInline\inlineManyFragments([$html1, $html2], $css); + + $this->assertCount(2, $results); + $this->assertStringContainsString('style="color: green;"', $results[0]); + $this->assertStringContainsString('style="color: green;"', $results[1]); + } + + public function testCssInlinerWithExtraCss(): void + { + $inliner = new CssInliner( + extraCss: 'p { color: green; }' + ); + + $html = '

Test

'; + $result = $inliner->inline($html); + + $this->assertStringContainsString('style="color: green;"', $result); + } + + public function testCssInlinerWithDisabledInlining(): void + { + $inliner = new CssInliner( + inlineStyleTags: false, + keepStyleTags: true, + ); + + $html = '

Test

'; + $result = $inliner->inline($html); + + $this->assertStringNotContainsString('style="color: blue;"', $result); + $this->assertStringContainsString('

Hello

'; + $html2 = '

World

'; + $results = $inliner->inlineMany([$html1, $html2]); + + $this->assertCount(2, $results); + $this->assertStringContainsString('style="color: blue;"', $results[0]); + $this->assertStringContainsString('style="color: red;"', $results[1]); + } + + public function testCssInlinerInlineManyFragments(): void + { + $inliner = new CssInliner(); + $html1 = '

Hello

'; + $html2 = '

World

'; + $css = 'h1 { color: orange; }'; + $results = $inliner->inlineManyFragments([$html1, $html2], $css); + + $this->assertCount(2, $results); + $this->assertStringContainsString('style="color: orange;"', $results[0]); + $this->assertStringContainsString('style="color: orange;"', $results[1]); + } + + public function testMultipleStylesInline(): void + { + $html = << + h1 { color: blue; font-size: 20px; } + p { margin: 10px; } + +

Title

+

Paragraph

+ HTML; + + $result = CssInline\inline($html); + + $this->assertStringContainsString('color: blue', $result); + $this->assertStringContainsString('font-size: 20px', $result); + $this->assertStringContainsString('margin: 10px', $result); + } + + public function testPreserveExistingInlineStyles(): void + { + $html = '

Hello

'; + $result = CssInline\inline($html); + + $this->assertStringContainsString('color: blue', $result); + $this->assertStringContainsString('font-size: 24px', $result); + } + + public function testKeepAtRules(): void + { + $inliner = new CssInliner( + keepAtRules: true, + ); + + $html = '

Test

'; + $result = $inliner->inline($html); + + $this->assertStringContainsString('style="color: blue;"', $result); + $this->assertStringContainsString('@media', $result); + } + + public function testMinifyCss(): void + { + $inliner = new CssInliner( + minifyCss: true, + ); + + $html = '

Test

'; + $result = $inliner->inline($html); + + // Minified CSS should not have trailing semicolon or extra spaces + $this->assertStringContainsString('style="color:blue;font-weight:bold"', $result); + } + + public function testStylesheetCache(): void + { + $cache = new StylesheetCache(size: 10); + $inliner = new CssInliner( + cache: $cache, + ); + + $html = '

Test

'; + $result = $inliner->inline($html); + + $this->assertIsString($result); + } + + public function testKeepLinkTags(): void + { + $inliner = new CssInliner( + keepLinkTags: true, + loadRemoteStylesheets: false, + ); + + $html = '

Test

'; + $result = $inliner->inline($html); + + $this->assertStringContainsString('assertStringNotContainsString('style="color: blue;"', $result); + $this->assertStringContainsString('data-css-inline="ignore"', $result); + } + + public function testDataCssInlineKeep(): void + { + $inliner = new CssInliner( + keepStyleTags: false, + ); + + $html = '

Hello

'; + $result = $inliner->inline($html); + + $this->assertStringContainsString('assertStringContainsString('style="color: blue;"', $result); + } + + public function testInvalidBaseUrl(): void + { + $this->expectException(\Exception::class); + + new CssInliner( + baseUrl: 'not-a-valid-url' + ); + } + + public function testInvalidCacheSize(): void + { + $this->expectException(\Exception::class); + + new StylesheetCache(size: 0); + } + + public function testNegativePreallocateNodeCapacity(): void + { + $this->expectException(\Exception::class); + + new CssInliner( + preallocateNodeCapacity: -1 + ); + } + + public function testPreallocateNodeCapacity(): void + { + $inliner = new CssInliner( + preallocateNodeCapacity: 100 + ); + + $html = '

Hello

'; + $result = $inliner->inline($html); + + $this->assertStringContainsString('style="color: blue;"', $result); + } + + public function testLoadRemoteStylesheetsDisabled(): void + { + $inliner = new CssInliner( + loadRemoteStylesheets: false, + ); + + // When remote stylesheets are disabled, link tags should be ignored + $html = '

Test

'; + $result = $inliner->inline($html); + + $this->assertIsString($result); + } +}