Skip to content

Commit

Permalink
Introduce PHP CSS and SCSS parser
Browse files Browse the repository at this point in the history
PHP CSS and SCSS parser
  • Loading branch information
overclokk authored May 9, 2024
2 parents 94a9304 + d488bb9 commit fc2ddfd
Show file tree
Hide file tree
Showing 12 changed files with 541 additions and 49 deletions.
9 changes: 4 additions & 5 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,13 @@

"justinrainbow/json-schema": "^5.2",
"scssphp/scssphp": "^1.12.1",
"sabberworm/php-css-parser": "^8.5",
"brick/varexporter": "^0.3.8",
"webimpress/safe-writer": "^2.2",

"symfony/polyfill-php80": "^1.22",
"symfony/event-dispatcher": "*",
"webmozart/assert": "*"
"symfony/event-dispatcher": "^5.4",
"webmozart/assert": "^1.11"
},
"require-dev": {
"lucatume/wp-browser": "^3.1",
Expand Down Expand Up @@ -64,9 +65,7 @@
"symplify/easy-coding-standard": "^12.0",

"italystrap/debug": "dev-master",
"wp-cli/wp-cli": "^2.7",

"sabberworm/php-css-parser": "^8.0"
"wp-cli/wp-cli": "^2.7"
},
"autoload": {
"psr-4": {
Expand Down
45 changes: 33 additions & 12 deletions docs/02-advanced-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,36 +325,47 @@ You can find an implementation example in the [tests/_data/fixtures/advanced-exa

### Custom CSS for Global Styles and per Block

The introduction of the `css` field in [WordPress 6.2](https://wordpress.org/news/2023/03/dolphy/) enables the addition of [custom CSS](https://make.wordpress.org/core/2023/03/06/custom-css-for-global-styles-and-per-block/) directly within the `theme.json` file, both globally under `styles.css` and per block within `styles.blocks.[block-name].css`. Utilizing the `\ItalyStrap\ThemeJsonGenerator\Domain\Input\Styles\Css` class and its `parseString(string $css, string $selector = ''): string` method, developers can now seamlessly integrate custom styles without the need to remember the format to use like spaces and separators, just write your CSS as you would in a regular CSS file and let the `Css` class handle the rest.
More information about the `css` field can be found in:

* [WordPress 6.2 release notes](https://wordpress.org/news/2023/03/dolphy/).
* [Custom CSS for Global Styles and per Block](https://make.wordpress.org/core/2023/03/06/custom-css-for-global-styles-and-per-block/).
* [Per Block CSS with theme.json](https://developer.wordpress.org/news/2023/04/21/per-block-css-with-theme-json/).
* [Global Settings and Styles](https://developer.wordpress.org/themes/global-settings-and-styles/).
* [How to use custom CSS in theme.json - fullsiteediting.com](https://fullsiteediting.com/lessons/how-to-use-custom-css-in-theme-json/).

The introduction of the `css` field in [WordPress 6.2](https://wordpress.org/news/2023/03/dolphy/) enables the addition of [custom CSS](https://make.wordpress.org/core/2023/03/06/custom-css-for-global-styles-and-per-block/) directly within the `theme.json` file, both globally under `styles.css` and per block within `styles.blocks.[block-name].css`. Utilizing the {`\ItalyStrap\ThemeJsonGenerator\Domain\Input\Styles\Css`|`\ItalyStrap\ThemeJsonGenerator\Domain\Input\Styles\Scss`} classes and theirs `parse(string $css, string $selector = ''): string` method, developers can now seamlessly integrate custom styles without the need to remember the format to use with `&` separator, just write your CSS (or Scss) as you would in a regular CSS|SCSS file and let the `Css`|`Scss` class handle the rest.

This method accepts two parameters: the CSS to parse and an optional selector to scope the CSS rules accordingly.

How It Works

The `Css` class efficiently parses the provided CSS, extracting and formatting rules based on the specified selector. This functionality ensures that the output is fully compatible with the `theme.json` structure, enhancing the flexibility and customization of theme development.
The `Css`|`Scss` class efficiently parses the provided CSS, extracting and formatting rules based on the specified selector. This functionality ensures that the output is fully compatible with the `theme.json` structure, enhancing the flexibility and customization of theme development.

So, let's see some examples:

```php
// Result: 'height: 100%;'
echo (new Css($presets))->parseString('.test-selector{height: 100%;}', '.test-selector');
echo (new Css($presets))->parse('.test-selector{height: 100%;}', '.test-selector');
```

```php
// Result: 'height: 100%;width: 100%;color: red;&:hover {color: red;}&::placeholder {color: red;}'
echo (new Css($presets))->parseString('.test-selector{height: 100%;width: 100%;color: red;}.test-selector:hover {color: red;}.test-selector::placeholder {color: red;}', '.test-selector');
echo (new Css($presets))->parse('.test-selector{height: 100%;width: 100%;color: red;}.test-selector:hover {color: red;}.test-selector::placeholder {color: red;}', '.test-selector');
```

Like the other classes in the `Styles` directory, you can use the `Css` class directly or pass the `$presets` collection to the constructor or use the `$container` object to get the instance you need.
Like the other classes in the `Styles` directory, you can use the `Css` class directly or pass the `$presets` collection to the constructor or use the `$container` object to get the instance you need, the only exception is for the `Scss` class that need an instance of `Css` class and an instance of `ScssPhp\ScssPhp\Compiler` class to work, but if you use the `$container` object you don't need to worry about it because all the dependencies are already registered in the container.

As the name suggests, the `Scss` class is used to parse SCSS styles, so you are free to write your styles in SCSS format and let the class handle the conversion for you.

Let's see in action:
Let's see it in action:

```php
use ItalyStrap\ThemeJsonGenerator\Domain\Input\Styles\Css;

[
SectionNames::STYLES => [
'css' => $container->get(Css::class) // Or (new Css($presets))
->parseString('.test-selector{height: 100%;width: 100%;color: red;}.test-selector:hover {color: red;}.test-selector::placeholder {color: red;}', '.test-selector'),
'css' => $container->get(Css::class) // Or (new Css($presets)) or (new Scss(new Css($presets), $scssCompiler, $presets))
->parse('.test-selector{height: 100%;width: 100%;color: red;}.test-selector:hover {color: red;}.test-selector::placeholder {color: red;}', '.test-selector'),
],
];
```
Expand All @@ -368,15 +379,15 @@ use ItalyStrap\ThemeJsonGenerator\Domain\Input\Styles\Css;
SectionNames::STYLES => [
'blocks' => [
'my-namespace/test-block' => [
'css' => $container->get(Css::class) // Or (new Css($presets))
->parseString('.test-selector{height: 100%;width: 100%;color: red;}.test-selector:hover {color: red;}.test-selector::placeholder {color: red;}', '.test-selector'),
'css' => $container->get(Css::class) // Or (new Css($presets)) or (new Scss(new Css($presets), $scssCompiler, $presets))
->parse('.test-selector{height: 100%;width: 100%;color: red;}.test-selector:hover {color: red;}.test-selector::placeholder {color: red;}', '.test-selector'),
],
],
],
];
```

All methods also support a special syntax to find value in the `$presets` collection, `{{type.slug}}`, this syntax will be used internally to find the value in the `$presets` collection.
All methods also support a special syntax to resolve value in the `$presets` collection, `{{type.slug}}`, this syntax will be used internally to find the value in the `$presets` collection registered before.


```php
Expand All @@ -385,11 +396,21 @@ use ItalyStrap\ThemeJsonGenerator\Domain\Input\Styles\Css;
[
SectionNames::STYLES => [
'css' => $container->get(Css::class) // Or (new Css($presets))
->parseString('.test-selector{color: {{color.base}};}', '.test-selector'),
->parse('.test-selector{color: {{color.base}};}', '.test-selector'),
],
];
```

The `{{color.base}}` will be replaced with the value of the `color.base` previously set in the `$presets` collection.

```json
{
"styles": {
"css": ".test-selector{color: var(--wp--preset--color--base);}"
}
}
```

More examples can be found in the [tests/_data/fixtures/advanced-example.json.php](../tests/_data/fixtures/advanced-example.json.php) file.

To know more about `css` field:
Expand Down
2 changes: 1 addition & 1 deletion src/Application/Config/Blueprint.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public function setGlobalCss(string $css): bool

public function appendGlobalCss(string $css): bool
{
$currentCss = $this->get(SectionNames::STYLES . '.css', '');
$currentCss = (string)$this->get(SectionNames::STYLES . '.css');
return $this->set(SectionNames::STYLES . '.css', $currentCss . $css);
}

Expand Down
92 changes: 83 additions & 9 deletions src/Domain/Input/Styles/Css.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,37 +7,111 @@
use ItalyStrap\Tests\Unit\Domain\Input\Styles\CssTest;
use ItalyStrap\ThemeJsonGenerator\Domain\Input\Settings\PresetsInterface;
use ItalyStrap\ThemeJsonGenerator\Domain\Input\Settings\NullPresets;
use Sabberworm\CSS\Parser;
use Sabberworm\CSS\Parsing\SourceException;
use Sabberworm\CSS\Property\Selector;

/**
* @link https://make.wordpress.org/core/2023/03/06/custom-css-for-global-styles-and-per-block/
* @link https://fullsiteediting.com/lessons/how-to-use-custom-css-in-theme-json/
* @link https://developer.wordpress.org/news/2023/04/21/per-block-css-with-theme-json/
* @link https://github.com/WordPress/wordpress-develop/blob/trunk/tests/phpunit/tests/theme/wpThemeJson.php
* @link https://developer.wordpress.org/themes/global-settings-and-styles/
*
* @link https://www.google.it/search?q=php+inline+css+content&sca_esv=596560865&ei=mAicZaTCGp3Axc8Pq7yT8AQ&ved=0ahUKEwik7p-Rgs6DAxUdYPEDHSveBE4Q4dUDCBA&uact=5&oq=php+inline+css+content&gs_lp=Egxnd3Mtd2l6LXNlcnAiFnBocCBpbmxpbmUgY3NzIGNvbnRlbnQyBRAhGKABMgUQIRigATIIECEYFhgeGB0yCBAhGBYYHhgdMggQIRgWGB4YHUjvogFQmgdYwJcBcAF4AZABAJgBsQGgAZkSqgEEMC4xOLgBA8gBAPgBAcICChAAGEcY1gQYsAPCAgoQABiABBiKBRhDwgIFEAAYgATCAgYQABgWGB7CAgcQABiABBgTwgIIEAAYFhgeGBPiAwQYACBBiAYBkAYI&sclient=gws-wiz-serp#ip=1
* @link https://github.com/topics/inline-css?l=php
* @link https://github.com/sabberworm/PHP-CSS-Parser
*
* @psalm-api
* @see CssTest
*/
class Css
class Css implements CssInterface
{
const M_AMPERSAND_MUST_NOT_BE_AT_THE_BEGINNING = 'CSS cannot begin with an ampersand (&)';

private PresetsInterface $presets;
private bool $isCompressed = true;
private bool $shouldResolveVariables = true;

public function __construct(
PresetsInterface $presets = null
) {
$this->presets = $presets ?? new NullPresets();
}

public function expanded(): self
{
$this->isCompressed = false;
return $this;
}

public function stopResolveVariables(): self
{
$this->shouldResolveVariables = false;
return $this;
}

/**
* @throws SourceException
*/
public function parse(string $css, string $selector = ''): string
{
if (\str_starts_with(\trim($css), '&')) {
throw new \RuntimeException(CssInterface::M_AMPERSAND_MUST_NOT_BE_AT_THE_BEGINNING);
}

if ($this->shouldResolveVariables) {
$css = $this->presets->parse($css);
}

$selector = \trim($selector);

if ($selector === '') {
return $css;
}

$parser = new Parser($css);
$doc = $parser->parse();

$rootRules = '';
$additionalSelectors = [];

$newLine = $this->isCompressed ? '' : PHP_EOL;
$newLineAfterBlock = $this->isCompressed ? '' : PHP_EOL . PHP_EOL;
$space = $this->isCompressed ? '' : \implode('', \array_fill(0, 4, ' '));
$spaceAfterSelector = $this->isCompressed ? '' : ' ';

foreach ($doc->getAllDeclarationBlocks() as $declarationBlock) {
foreach ($declarationBlock->getSelectors() as $cssSelector) {
if (\is_string($cssSelector)) {
$cssSelector = new Selector($cssSelector);
}

if ($cssSelector->getSelector() === $selector) {
foreach ($declarationBlock->getRules() as $rule) {
$ruleText = $rule->getRule() . ': ' . (string)$rule->getValue() . ';' . $newLine;
$rootRules .= $ruleText;
}

continue;
}

$actualSelector = $cssSelector->getSelector();
$newSelector = \substr($actualSelector, \strlen($selector));

$cssBlock = $newSelector . $spaceAfterSelector . '{' . $newLine;
foreach ($declarationBlock->getRules() as $rule) {
$cssBlock .= $space . $rule->getRule() . ': ' . (string)$rule->getValue() . ';' . $newLine;
}
$cssBlock .= '}' . $newLineAfterBlock;
$additionalSelectors[] = $cssBlock;
}
}

\array_unshift($additionalSelectors, $rootRules . $newLine);
return \trim(\implode('&', $additionalSelectors), "\t\n\r\0\x0B&");
}

/**
* @deprecated Use parse() instead
*/
public function parseString(string $css, string $selector = ''): string
{
if (\str_starts_with(\trim($css), '&')) {
throw new \RuntimeException(self::M_AMPERSAND_MUST_NOT_BE_AT_THE_BEGINNING);
throw new \RuntimeException(CssInterface::M_AMPERSAND_MUST_NOT_BE_AT_THE_BEGINNING);
}

$css = $this->presets->parse($css);
Expand Down
17 changes: 17 additions & 0 deletions src/Domain/Input/Styles/CssInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace ItalyStrap\ThemeJsonGenerator\Domain\Input\Styles;

/**
* @psalm-api
*/
interface CssInterface
{
public const M_AMPERSAND_MUST_NOT_BE_AT_THE_BEGINNING = 'CSS cannot begin with an ampersand (&)';

public function expanded(): self;

public function parse(string $css, string $selector = ''): string;
}
65 changes: 65 additions & 0 deletions src/Domain/Input/Styles/Scss.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

declare(strict_types=1);

namespace ItalyStrap\ThemeJsonGenerator\Domain\Input\Styles;

use ItalyStrap\Tests\Unit\Domain\Input\Styles\ScssTest;
use ItalyStrap\ThemeJsonGenerator\Domain\Input\Settings\NullPresets;
use ItalyStrap\ThemeJsonGenerator\Domain\Input\Settings\PresetsInterface;
use ScssPhp\ScssPhp\Compiler;
use ScssPhp\ScssPhp\OutputStyle;

/**
* @psalm-api
* @see ScssTest
*/
class Scss implements CssInterface
{
private Css $css;
private Compiler $compiler;
private PresetsInterface $presets;

/**
* @var 'compressed'|'expanded'
*/
private string $outputStyle = OutputStyle::COMPRESSED;

public function __construct(
Css $css,
Compiler $compiler,
PresetsInterface $presets = null
) {
$this->css = $css;
$this->compiler = $compiler;
$this->presets = $presets ?? new NullPresets();
}

public function expanded(): self
{
$this->css->expanded();
$this->outputStyle = OutputStyle::EXPANDED;
return $this;
}

public function parse(string $css, string $selector = ''): string
{
if (\str_starts_with(\trim($css), '&')) {
throw new \RuntimeException(CssInterface::M_AMPERSAND_MUST_NOT_BE_AT_THE_BEGINNING);
}

$this->css->stopResolveVariables();
$css = $this->presets->parse($css);

$selector = \trim($selector);

if ($selector === '') {
return $css;
}

$this->compiler->setOutputStyle($this->outputStyle);
$cssCompiled = $this->compiler->compileString($css);

return $this->css->parse($cssCompiled->getCss(), $selector);
}
}
35 changes: 34 additions & 1 deletion tests/integration/Domain/Input/Styles/CssTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ class CssTest extends IntegrationTestCase
{
use ProcessBlocksCustomCssTrait;
use CssStyleStringProviderTrait {
styleProvider as styleProviderTrait;
CssStyleStringProviderTrait::styleProvider as styleProviderTrait;
CssStyleStringProviderTrait::newStyleProvider as newStyleProviderTrait;
}

private function makeInstance(): Css
Expand All @@ -36,6 +37,38 @@ public function testItProcessInRealScenario(string $selector, string $actual, st
$this->assertSame($expected, $parseString, 'The parsed string is not the same as expected');


$result = $this->process_blocks_custom_css(
$parseString,
$selector
);

$this->assertSame($actual, $result, 'The result string is not the same as original');
}

public static function newStyleProvider(): iterable
{
// foreach (self::newStyleProviderTrait() as $key => $value) {
// yield $key => $value;
// }

yield 'root custom properties mixed with css' => [
// phpcs:disable
'selector' => '.test-selector',
'actual' => '.test-selector{--foo: 100%;--bar: 100%;}.test-selector #firstParagraph{background-color: var(--first-color);color: var(--second-color);}.test-selector .foo{--bar: 50%;color: red;width: var(--foo);height: var(--bar);}',
'expected' => '--foo: 100%;--bar: 100%;& #firstParagraph{background-color: var(--first-color);color: var(--second-color);}& .foo{--bar: 50%;color: red;width: var(--foo);height: var(--bar);}',
// phpcs:enable
];
}

/**
* @dataProvider newStyleProvider
*/
public function testNewItProcessInRealScenario(string $selector, string $actual, string $expected): void
{
$parseString = $this->makeInstance()->parseString($actual, $selector);
$this->assertSame($expected, $parseString, 'The parsed string is not the same as expected');


$result = $this->process_blocks_custom_css(
$parseString,
$selector
Expand Down
Loading

0 comments on commit fc2ddfd

Please sign in to comment.