From 38c8c50ed2a6c6dab200f173d06bf5fb6351c9b2 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 9 Nov 2026 12:30:47 -0500 Subject: [PATCH] Bind font-shorthand custom property families to font-family presets Move CssFontShorthand into the shared Css layer so WordPressSitePlan can parse shorthand values, and rewrite only the exactly matching family part of a custom property in place. Non-matching values fall through to the existing colour token binding. Fixes #5215 --- .../Style => Css}/CssFontShorthand.php | 23 +++++++--- .../Style/DesignUsageCollector.php | 1 + .../src/HtmlToBlocks/Style/StyleResolver.php | 1 + .../src/WordPressSitePlan/PresetBinding.php | 19 ++++++++- ...binding-font-shorthand-custom-property.php | 42 +++++++++++++++++++ .../unit/font-shorthand-resolved-cascade.php | 10 ++++- 6 files changed, 88 insertions(+), 8 deletions(-) rename php-transformer/src/{HtmlToBlocks/Style => Css}/CssFontShorthand.php (74%) create mode 100644 php-transformer/tests/contract/preset-binding-font-shorthand-custom-property.php diff --git a/php-transformer/src/HtmlToBlocks/Style/CssFontShorthand.php b/php-transformer/src/Css/CssFontShorthand.php similarity index 74% rename from php-transformer/src/HtmlToBlocks/Style/CssFontShorthand.php rename to php-transformer/src/Css/CssFontShorthand.php index 16fc002ed..8a0286f2c 100644 --- a/php-transformer/src/HtmlToBlocks/Style/CssFontShorthand.php +++ b/php-transformer/src/Css/CssFontShorthand.php @@ -1,17 +1,20 @@ Empty when the value cannot safely be expanded. */ - public static function expand(string $value, bool $allowFluidSize = false): array + /** + * @param int|null $familyOffset Receives the family start in the original value. + * @return array Empty when the value cannot safely be expanded. + */ + public static function expand(string $value, bool $allowFluidSize = false, ?int &$familyOffset = null): array { - $important = CssValueInspector::isImportant($value) ? ' !important' : ''; + $familyOffset = null; + $leading = strlen($value) - strlen(ltrim($value)); + $important = 1 === preg_match('/\s*!\s*important\s*$/i', $value) ? ' !important' : ''; $value = trim(preg_replace('/\s*!important\s*$/i', '', $value) ?? $value); // Substitution and system/CSS-wide keywords require computed-value data. if (!$allowFluidSize && preg_match('/\bvar\s*\(/i', $value)) return array(); @@ -36,6 +39,14 @@ public static function expand(string $value, bool $allowFluidSize = false): arra if (array() === $tokens) return array(); $result['line-height'] = $lineHeight; $result['font-family'] = implode(' ', $tokens); + // Locate the remaining tokens in the source rather than rebuilding + // the prefix: shorthand whitespace and spelling must survive binding. + $offset = strlen($value); + foreach (array_reverse($tokens) as $familyToken) { + $offset = strrpos(substr($value, 0, $offset), $familyToken); + if (false === $offset) return array(); + } + $familyOffset = $leading + $offset; foreach ($result as &$longhand) $longhand .= $important; unset($longhand); return $result; diff --git a/php-transformer/src/HtmlToBlocks/Style/DesignUsageCollector.php b/php-transformer/src/HtmlToBlocks/Style/DesignUsageCollector.php index 10c62c7e3..36a9571a4 100644 --- a/php-transformer/src/HtmlToBlocks/Style/DesignUsageCollector.php +++ b/php-transformer/src/HtmlToBlocks/Style/DesignUsageCollector.php @@ -4,6 +4,7 @@ namespace Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style; use Automattic\BlocksEngine\PhpTransformer\Css\CssColor; +use Automattic\BlocksEngine\PhpTransformer\Css\CssFontShorthand; use Automattic\BlocksEngine\PhpTransformer\Css\CssLength; use Automattic\BlocksEngine\PhpTransformer\Css\CssValueSplitter; use DOMElement; diff --git a/php-transformer/src/HtmlToBlocks/Style/StyleResolver.php b/php-transformer/src/HtmlToBlocks/Style/StyleResolver.php index e1e8035b9..82f291fce 100644 --- a/php-transformer/src/HtmlToBlocks/Style/StyleResolver.php +++ b/php-transformer/src/HtmlToBlocks/Style/StyleResolver.php @@ -3,6 +3,7 @@ namespace Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style; +use Automattic\BlocksEngine\PhpTransformer\Css\CssFontShorthand; use Automattic\BlocksEngine\PhpTransformer\Css\CssIdent; use Automattic\BlocksEngine\PhpTransformer\Css\CssPropertyName; use Automattic\BlocksEngine\PhpTransformer\Css\CssSelectorMatcher; diff --git a/php-transformer/src/WordPressSitePlan/PresetBinding.php b/php-transformer/src/WordPressSitePlan/PresetBinding.php index 0db6d6afa..00dbc6b81 100644 --- a/php-transformer/src/WordPressSitePlan/PresetBinding.php +++ b/php-transformer/src/WordPressSitePlan/PresetBinding.php @@ -6,6 +6,7 @@ use Automattic\BlocksEngine\PhpTransformer\Contract\CaptureEvidence; use Automattic\BlocksEngine\PhpTransformer\Contract\ResponsiveCorrespondence; use Automattic\BlocksEngine\PhpTransformer\Css\CssColor; +use Automattic\BlocksEngine\PhpTransformer\Css\CssFontShorthand; use Automattic\BlocksEngine\PhpTransformer\Css\CssLength; use Automattic\BlocksEngine\PhpTransformer\Css\CssSelectorMatcher; use Automattic\BlocksEngine\PhpTransformer\Css\CssStylesheetTransformer; @@ -938,7 +939,9 @@ private function bindBlock(string $name, string $json, string $markup, int $afte * for a block literal. * - A font size binds only when it is the preset's own length, and a * font family only when it is the preset's own stack, so no fallback - * or rounding changes what renders. + * or rounding changes what renders. Font-shorthand custom properties + * bind only an exactly matching complete stack, in place, leaving the + * rest of the shorthand literal. */ public function bindStylesheet(string $css): string { @@ -1157,6 +1160,20 @@ private function bindDeclaration(string $declaration, bool $rootSelector = false $value = trim(substr($value, 0, -strlen($match[0]))); } if ('' === $value) return $declaration; + if (str_starts_with($property, '--')) { + $font = CssFontShorthand::expand($value, false, $familyOffset); + if (isset($font['font-family'])) { + $family = substr($value, $familyOffset); + $slug = $this->exactFontFamilySlug($family); + foreach ($this->system->fontFamilies as $preset) { + if (null !== $slug && $preset['slug'] === $slug && $this->sameFont($family, $preset['fontFamily'])) { + ++$this->bound; + return self::sourcePresets($parts[0]) . ':' . self::sourcePresets(substr($value, 0, $familyOffset)) + . 'var(--wp--preset--font-family--' . $slug . ')' . $important; + } + } + } + } if ((str_starts_with($property, '--') || in_array($property, array('background', 'background-image'), true)) && null === CssColor::parse($this->resolve($value))) { $bound = $this->bindColorTokens($value); return $bound === self::sourcePresets($value) ? $declaration : self::sourcePresets($parts[0]) . ':' . $bound . $important; diff --git a/php-transformer/tests/contract/preset-binding-font-shorthand-custom-property.php b/php-transformer/tests/contract/preset-binding-font-shorthand-custom-property.php new file mode 100644 index 000000000..4197bfc16 --- /dev/null +++ b/php-transformer/tests/contract/preset-binding-font-shorthand-custom-property.php @@ -0,0 +1,42 @@ +
' + . str_repeat('

Body copy with a typography token.

', 6) . '
'; +$plan = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => array('index.html' => $html)))->toArray()['source_reports']['wordpress_site_plan']; +$css = ''; +$families = $palette = array(); +foreach ($plan['writes'] as $write) { + if ('theme.json' === $write['target_path']) { + $theme = json_decode($write['payload']['data'], true, 512, JSON_THROW_ON_ERROR); + $families = array_column($theme['settings']['typography']['fontFamilies'], 'fontFamily', 'slug'); + $palette = array_column($theme['settings']['color']['palette'], 'color', 'slug'); + } + if (str_ends_with($write['target_path'], '.css')) $css .= $write['payload']['data']; +} +$slug = array_search($stack, $families, true); +$colorSlug = array_search('#2f2e2e', $palette, true); +$assert(is_string($slug), 'The rendered literal stack has an exactly matching preset.'); +$assert(is_string($colorSlug), 'The rendered colour has an exactly matching preset.'); +// Print the relevant emitted declarations as before/after evidence, even on failure. +preg_match_all('/--(?:t|unmatched):[^;}]+|color:var\(--wp--preset--color--[^;}]+/', $css, $definitions); +echo implode("\n", array_unique($definitions[0])) . "\n"; +$assert(str_contains($css, '--unmatched:italic 700 21px / 1.4 Unknown Face,serif'), 'An unmatched shorthand stays literal.'); +$assert(str_contains($css, '--near:18px Example Light,serif'), 'Sharing only a primary family is not an exact stack match.'); +$assert(str_contains($css, '--spaced:italic 700 18px / 1.6em var(--wp--preset--font-family--' . $slug . ')!important'), 'Style, weight, size, line-height, spacing and priority survive family binding.'); +$assert(str_contains($css, 'color:var(--wp--preset--color--' . $colorSlug . ',rgb(var(--c)))'), 'Colour variable binding retains its original fallback.'); +$assert(str_contains($css, '--sh:0 0 2px var(--wp--preset--color--' . $colorSlug . ',#2f2e2e)'), 'A non-font custom property keeps its colour token binding.'); +$assert(str_contains($css, '--t:normal normal normal 18px/1.6em var(--wp--preset--font-family--' . $slug . ')'), 'Only the matching shorthand family is bound in place.'); +echo "Font shorthand custom property binding contract passed.\n"; diff --git a/php-transformer/tests/unit/font-shorthand-resolved-cascade.php b/php-transformer/tests/unit/font-shorthand-resolved-cascade.php index 429f098e6..e8b8ac192 100644 --- a/php-transformer/tests/unit/font-shorthand-resolved-cascade.php +++ b/php-transformer/tests/unit/font-shorthand-resolved-cascade.php @@ -5,7 +5,7 @@ require dirname(__DIR__, 2) . '/vendor/autoload.php'; use Automattic\BlocksEngine\PhpTransformer\ArtifactCompiler\ArtifactCompiler; -use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\CssFontShorthand; +use Automattic\BlocksEngine\PhpTransformer\Css\CssFontShorthand; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\StyleResolver; $assert = static function (bool $ok, string $message): void { @@ -51,6 +51,14 @@ foreach (array('font-style' => 'italic', 'font-variant' => 'small-caps', 'font-weight' => 'bold', 'font-size' => '18px', 'line-height' => 'normal', 'font-family' => '"PT Sans",Arial,sans-serif') as $property => $value) { $assert($value . ' !important' === ($expanded[$property] ?? null), 'expand ' . $property); } +foreach (array( + ' italic 700 18px / 1.6em "Example Light", Example, sans-serif', + 'normal normal normal 18px/1.6em Example Light,Example,sans-serif', +) as $shorthand) { + $font = CssFontShorthand::expand($shorthand, false, $familyOffset); + $assert(isset($font['font-family']) && null !== $familyOffset, 'ordinary shorthands expose a family offset'); + $assert(str_starts_with(substr($shorthand, $familyOffset), '"Example Light"') || str_starts_with(substr($shorthand, $familyOffset), 'Example Light'), 'family offset preserves the original prefix bytes'); +} // The same block is read by both authored projection and cascade consumers. $resolver = (new ReflectionClass(StyleResolver::class))->newInstanceWithoutConstructor(); foreach (array('font:bold 18px Arial' => 'bold 18px Arial') as $style => $font) {