Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions php-transformer/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,14 @@
"php tests/unit/engine-support-css-asset.php",
"php tests/unit/engine-support-css-specificity.php",
"php tests/unit/inline-display-carrier.php",
"php tests/unit/css-owned-flex-carrier.php",
"php tests/unit/auto-fit-grid-carrier.php",
"php tests/unit/artifact-author-stylesheet-projection.php",
"php tests/unit/fallback-finding-normalizer.php",
"php tests/unit/navigation-underline-color-resolver.php",
"php tests/unit/button-signal-classifier.php",
"php tests/unit/button-style-resolver.php",
"php tests/unit/button-font-family-carry.php",
"php tests/unit/button-visual-probe-diagnostics.php",
"php tests/unit/shell-landmark-policy.php",
"php tests/unit/block-style-support-conversion.php",
Expand Down
3 changes: 3 additions & 0 deletions php-transformer/src/HtmlToBlocks/BlockFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -979,6 +979,9 @@ private function buttonStyleSupport(array $attrs): array
$classes[] = 'has-custom-font-size';
}
$typographyMap = array(
// A raw authored family is a custom value, so core's style engine
// serializes it inline rather than as a has-*-font-family class.
'fontFamily' => 'font-family',
'fontSize' => 'font-size',
'fontWeight' => 'font-weight',
'letterSpacing' => 'letter-spacing',
Expand Down
62 changes: 62 additions & 0 deletions php-transformer/src/HtmlToBlocks/HtmlTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,21 @@ final class HtmlTransformer
'place-items',
);

/** @var list<string> Inline flex declarations carried to the generated stylesheet for css-owned flex containers. */
private const CSS_OWNED_FLEX_CARRIER_PROPERTIES = array(
'display',
'flex-flow',
'flex-direction',
'flex-wrap',
'gap',
'row-gap',
'column-gap',
'align-content',
'align-items',
'justify-content',
'place-content',
);

private const CSS_OWNED_LAYOUT_ITEM_CLASS = 'blocks-engine-css-owned-layout-item';

/** @var array<string, string> Source control DOM paths mapped to core/button wrapper classes. */
Expand Down Expand Up @@ -4403,6 +4418,10 @@ private function cssOwnedGroupAttributes(DOMElement $element): array
return $this->cssOwnedGridAttributes($element);
}

if ( $this->isCssOwnedFlexElement($element) ) {
$attrs = $this->cssOwnedFlexAttributes($element);
}

unset($attrs['layout']);
$attrs['className'] = $this->mergeClassNames(
(string) ($attrs['className'] ?? ''),
Expand All @@ -4423,6 +4442,49 @@ private function cssOwnedGroupAttributes(DOMElement $element): array
return $attrs;
}

private function isCssOwnedFlexElement(DOMElement $element): bool
{
$display = strtolower(trim((string) preg_replace(
'/\s*!important\s*$/i',
'',
(string) ($this->structuralPresentationDeclarations($element)['display'] ?? '')
)));

return in_array($display, array( 'flex', 'inline-flex' ), true);
}

/**
* Attributes for a block hosting an author flex container demoted to CSS
* ownership. The demotion below drops the native `layout` attribute, which
* was the only thing expressing the flex container, so without carrying the
* authored `display:flex` the children stack. The inline declarations ride
* to the generated stylesheet on a carrier class exactly as
* CSS_OWNED_GRID_CARRIER_PROPERTIES does for grids; class-owned ones are
* already retained by author stylesheet materialization.
*
* An inline display that overrides class-owned layout is already carried
* complete by inlineGeometryClassName(), at the non-important specificity
* tier that keeps authored !important rules winning. Forcing those same
* properties would move them to the !important tier, so that case is left
* alone.
*
* @return array<string, mixed>
*/
private function cssOwnedFlexAttributes(DOMElement $element): array
{
$inlineDeclarations = $this->cssDeclarations($this->attr($element, 'style'));
if ( $this->inlineDisplayOverridesAuthorLayout($element, $inlineDeclarations) ) {
return $this->presentationAttributes($element);
}

// Carry only the inline-present properties so the fallback to
// mapper-synthesized declarations cannot invent a `gap` that
// overrides explicit row-gap/column-gap values.
$carriedProperties = array_values(array_intersect(self::CSS_OWNED_FLEX_CARRIER_PROPERTIES, array_keys($inlineDeclarations)));

return $this->presentationAttributes($element, array(), $carriedProperties);
}

private function isCssOwnedGridElement(DOMElement $element): bool
{
$display = strtolower(trim((string) preg_replace(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,16 @@ final class ButtonStyleResolver
{
/**
* Typography supports projected onto buttons, in canonical emission order.
*
* fontFamily belongs here: core/button registers a `fontFamily` attribute,
* which core injects only when the typography fontFamily support is enabled.
* A raw authored value is not a preset slug, so it rides in
* style.typography.fontFamily and serializes inline on the link. Dropping it
* left the typeface to theme.json's styles.elements.button, because the
* authored class is consumed into block attributes and its rewritten rule no
* longer wins the cascade.
*/
private const BUTTON_TYPOGRAPHY = array( 'fontSize', 'fontWeight', 'letterSpacing', 'lineHeight', 'textTransform' );
private const BUTTON_TYPOGRAPHY = array( 'fontFamily', 'fontSize', 'fontWeight', 'letterSpacing', 'lineHeight', 'textTransform' );

private readonly StyleAttributeMapper $mapper;

Expand Down
17 changes: 12 additions & 5 deletions php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -1993,15 +1993,22 @@ private function layoutFlexWrap(string $value): string
}

/**
* A track list of exactly repeat(auto-fit|auto-fill, minmax(<width>, 1fr))
* is natively expressible as WordPress grid layout: core renders
* A track list of exactly repeat(auto-fill, minmax(<width>, 1fr)) is
* natively expressible as WordPress grid layout: core renders
* minimumColumnWidth as repeat(auto-fill, minmax(min(<width>, 100%), 1fr)).
* Every other track list (fixed counts, asymmetric tracks, nested
* functions) returns '' and stays under author CSS ownership.
*
* auto-fit is deliberately excluded. wp-includes/block-supports/layout.php
* hardcodes auto-fill in every branch that renders minimumColumnWidth, so
* the attribute cannot express auto-fit at all. The two keywords differ in
* rendered geometry — auto-fit collapses tracks left empty, auto-fill
* retains them — so converting auto-fit would keep the empty tracks and
* squeeze the real content into part of the measure. Like every other track
* list WordPress cannot express (fixed counts, asymmetric tracks, nested
* functions), auto-fit returns '' and stays under author CSS ownership.
*/
private function autoRepeatMinimumColumnWidth(string $tracks): string
{
if ( 1 === preg_match('/^repeat\(\s*auto-(?:fit|fill)\s*,\s*minmax\(\s*([0-9]*\.?[0-9]+(?:px|rem|em|ch|ex|vw|vh|vmin|vmax|%))\s*,\s*1fr\s*\)\s*\)$/i', trim($tracks), $matches)
if ( 1 === preg_match('/^repeat\(\s*auto-fill\s*,\s*minmax\(\s*([0-9]*\.?[0-9]+(?:px|rem|em|ch|ex|vw|vh|vmin|vmax|%))\s*,\s*1fr\s*\)\s*\)$/i', trim($tracks), $matches)
&& 0.0 < (float) $matches[1]
) {
return strtolower($matches[1]);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"schema": "blocks-engine/php-transformer/parity-fixture/v1",
"name": "html-autofit-grid-carries-gap-and-background",
"description": "An auto-fit grid using the hairline-divider technique (gap:1px plus a container background painting through the gaps) keeps its native grid tracks while source CSS owns its gap: core/group save markup cannot serialize blockGap. The container background remains a color support so the dividers survive without the author stylesheet.",
"description": "An auto-fit grid using the hairline-divider technique (gap:1px plus a container background painting through the gaps) stays entirely under author CSS ownership: core's grid layout support hardcodes auto-fill, which would retain the tracks auto-fit collapses. The author rule keeps the tracks, the hairline gap, and the divider background together, so the technique survives intact rather than being split across a native layout attribute and a color support.",
"source_reference": {
"repo": "php-transformer",
"path": "tests/fixtures/parity/html-autofit-grid-carries-gap-and-background.json",
"notes": "Derived from a portfolio work grid where gap:1px;background:var(--ink) painted hairline separators between cells. The source stylesheet retains the gap because core/group does not serialize blockGap in canonical save markup."
"notes": "Derived from a portfolio work grid where gap:1px;background:var(--ink) painted hairline separators between cells. The gap and the background must stay with the track list in one author rule: promoting either to a block support while the tracks stay in CSS splits a single visual technique across two owners."
},
"legacy_comparison": {
"skip": true,
Expand All @@ -20,7 +20,8 @@
"path": "blocks.0",
"name": "core/group",
"attrs": {
"layout": { "type": "grid", "minimumColumnWidth": "240px" }
"layout": null,
"className": "work-grid blocks-engine-css-owned-layout blocks-engine-css-owned-grid"
}
}
],
Expand All @@ -29,7 +30,9 @@
{ "path": "status", "assert": "equals", "value": "success" },
{ "path": "fallbacks", "assert": "count", "count": 0 },
{ "path": "blocks.0.attrs.style.spacing.blockGap", "assert": "equals", "value": null },
{ "path": "blocks.0.attrs.style.color.background", "assert": "equals", "value": "#1a1a1a" },
{ "path": "serialized_blocks", "assert": "contains", "value": "is-layout-grid" }
{ "path": "blocks.0.attrs.style.color.background", "assert": "equals", "value": null },
{ "path": "serialized_blocks", "assert": "not_contains", "value": "is-layout-grid" },
{ "path": "serialized_blocks", "assert": "contains", "value": "blocks-engine-css-owned-grid" },
{ "path": "assets.1.content", "assert": "contains", "value": ".work-grid{display:grid;grid-template-columns:repeat(auto-fit, minmax(240px, 1fr));gap:1px;background:#1a1a1a}" }
]
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"schema": "blocks-engine/php-transformer/parity-fixture/v1",
"name": "html-autofit-grid-inline-leaf-items",
"description": "An expressible auto-fit grid whose direct children are standalone inline text leaves keeps native grid layout while source CSS owns its gap. Each leaf rides its own display:contents carrier paragraph, so the item count matches the source children and the spans themselves become the grid items. Locks the standalone-inline-leaf routing branch, which bypasses cssOwnedGroupAttributes.",
"description": "An auto-fit grid whose direct children are standalone inline text leaves keeps the author rule as the owner of its tracks and gap, because core's grid layout support cannot express auto-fit. Each leaf still rides its own display:contents carrier paragraph, so the item count matches the source children and the spans themselves become the grid items. Locks the standalone-inline-leaf routing branch, which bypasses cssOwnedGroupAttributes.",
"source_reference": {
"repo": "php-transformer",
"path": "tests/fixtures/parity/html-autofit-grid-inline-leaf-items.json",
"notes": "Derived from client-logo strips built as bare styled spans inside repeat(auto-fit, minmax(W, 1fr)) grids. Guards against two failure modes: adjacent inline leaves coalescing into one paragraph (one grid item instead of N) and the branch dropping the native layout back to a vertical stack."
"notes": "Derived from client-logo strips built as bare styled spans inside repeat(auto-fit, minmax(W, 1fr)) grids. Guards against two failure modes: adjacent inline leaves coalescing into one paragraph (one grid item instead of N) and the retained author rule losing its display:grid, which would stack the strip vertically."
},
"legacy_comparison": {
"skip": true,
Expand All @@ -19,7 +19,7 @@
{
"path": "blocks.0",
"name": "core/group",
"attrs": { "layout": { "type": "grid", "minimumColumnWidth": "190px" } }
"attrs": { "layout": null, "className": "client-strip blocks-engine-css-owned-layout blocks-engine-css-owned-flow" }
},
{ "path": "blocks.0.innerBlocks.0", "name": "core/paragraph" },
{ "path": "blocks.0.innerBlocks.3", "name": "core/paragraph" }
Expand All @@ -30,7 +30,8 @@
{ "path": "fallbacks", "assert": "count", "count": 0 },
{ "path": "blocks.0.innerBlocks", "assert": "count", "count": 4 },
{ "path": "blocks.0.attrs.style.spacing.blockGap", "assert": "equals", "value": null },
{ "path": "serialized_blocks", "assert": "contains", "value": "is-layout-grid" },
{ "path": "serialized_blocks", "assert": "contains", "value": "<p class=\"blocks-engine-inline-layout-carrier\"><span class=\"client\">Acme Corp</span></p>" }
{ "path": "serialized_blocks", "assert": "not_contains", "value": "is-layout-grid" },
{ "path": "serialized_blocks", "assert": "contains", "value": "<p class=\"blocks-engine-inline-layout-carrier\"><span class=\"client\">Acme Corp</span></p>" },
{ "path": "assets.1.content", "assert": "contains", "value": ".client-strip{display:grid;grid-template-columns:repeat(auto-fit, minmax(190px, 1fr));gap:24px}" }
]
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"schema": "blocks-engine/php-transformer/parity-fixture/v1",
"name": "html-autofit-grid-stays-css-owned",
"description": "An author-CSS grid container using repeat(auto-fit, minmax(W, 1fr)) is NOT expressible as native WordPress grid layout: wp-includes/block-supports/layout.php hardcodes auto-fill in every branch that renders minimumColumnWidth, and auto-fill retains the tracks auto-fit collapses. The container must take the css-owned-GRID path, where the author rule keeps owning the exact authored track list — not the css-owned-FLOW demotion, which drops display:grid and stacks the cards in a single column.",
"source_reference": {
"repo": "php-transformer",
"path": "tests/fixtures/parity/html-autofit-grid-stays-css-owned.json",
"notes": "Derived from a portfolio homepage where .work-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)) } collapsed to a vertical stack after transform, because the demotion at the time dropped display:grid along with the layout attribute. Converting to a native layout attribute traded that stack for a different wrong geometry: WordPress rendered the tracks as auto-fill, retaining empty tracks and squeezing the cards into part of the measure. The css-owned-grid carrier is the path that reproduces the authored tracks exactly."
},
"legacy_comparison": {
"skip": true,
"reason": "Covers current PHP transformer layout classification behavior; no downstream legacy comparison."
},
"operation": "html_transformer.transform",
"input": {
"content": "<html><head><style>.work-grid{display:grid;grid-template-columns:repeat(auto-fit, minmax(240px, 1fr));gap:24px}.work-card h3{margin:0}</style></head><body><section class=\"work-grid\"><div class=\"work-card\"><h3>Ledger</h3><p>Design system for a fintech team.</p></div><div class=\"work-card\"><h3>Atlas</h3><p>Mapping tools for field research.</p></div><div class=\"work-card\"><h3>Relay</h3><p>Realtime dashboard for dispatch.</p></div></section></body></html>"
},
"expected_blocks": [
{
"path": "blocks.0",
"name": "core/group",
"attrs": { "layout": null, "className": "work-grid blocks-engine-css-owned-layout blocks-engine-css-owned-grid" }
}
],
"expected_fallbacks": [],
"expect": [
{ "path": "status", "assert": "equals", "value": "success" },
{ "path": "fallbacks", "assert": "count", "count": 0 },
{ "path": "blocks.0.innerBlocks", "assert": "count", "count": 3 },
{ "path": "serialized_blocks", "assert": "not_contains", "value": "is-layout-grid" },
{ "path": "serialized_blocks", "assert": "not_contains", "value": "minimumColumnWidth" },
{ "path": "serialized_blocks", "assert": "not_contains", "value": "blocks-engine-css-owned-flow" },
{ "path": "serialized_blocks", "assert": "contains", "value": "blocks-engine-css-owned-grid" },
{ "path": "assets.1.content", "assert": "contains", "value": ".work-grid{display:grid;grid-template-columns:repeat(auto-fit, minmax(240px, 1fr));gap:24px}" }
]
}
Loading
Loading