diff --git a/php-transformer/composer.json b/php-transformer/composer.json index 7d7a140e..a49b7213 100644 --- a/php-transformer/composer.json +++ b/php-transformer/composer.json @@ -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", diff --git a/php-transformer/src/HtmlToBlocks/BlockFactory.php b/php-transformer/src/HtmlToBlocks/BlockFactory.php index 9fc0b616..725a9ebd 100644 --- a/php-transformer/src/HtmlToBlocks/BlockFactory.php +++ b/php-transformer/src/HtmlToBlocks/BlockFactory.php @@ -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', diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index 10a0b185..a1c2f86d 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -453,6 +453,21 @@ final class HtmlTransformer 'place-items', ); + /** @var list 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 Source control DOM paths mapped to core/button wrapper classes. */ @@ -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'] ?? ''), @@ -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 + */ + 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( diff --git a/php-transformer/src/HtmlToBlocks/Patterns/ButtonStyleResolver.php b/php-transformer/src/HtmlToBlocks/Patterns/ButtonStyleResolver.php index 17909afd..f98b8459 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/ButtonStyleResolver.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/ButtonStyleResolver.php @@ -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; diff --git a/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php b/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php index a9cd2e29..3b67768c 100644 --- a/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php +++ b/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php @@ -1993,15 +1993,22 @@ private function layoutFlexWrap(string $value): string } /** - * A track list of exactly repeat(auto-fit|auto-fill, minmax(, 1fr)) - * is natively expressible as WordPress grid layout: core renders + * A track list of exactly repeat(auto-fill, minmax(, 1fr)) is + * natively expressible as WordPress grid layout: core renders * minimumColumnWidth as repeat(auto-fill, minmax(min(, 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]); diff --git a/php-transformer/tests/fixtures/parity/html-autofit-grid-carries-gap-and-background.json b/php-transformer/tests/fixtures/parity/html-autofit-grid-carries-gap-and-background.json index 13649d1e..bbc3acce 100644 --- a/php-transformer/tests/fixtures/parity/html-autofit-grid-carries-gap-and-background.json +++ b/php-transformer/tests/fixtures/parity/html-autofit-grid-carries-gap-and-background.json @@ -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, @@ -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" } } ], @@ -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}" } ] } diff --git a/php-transformer/tests/fixtures/parity/html-autofit-grid-inline-leaf-items.json b/php-transformer/tests/fixtures/parity/html-autofit-grid-inline-leaf-items.json index cce091c7..88d766e6 100644 --- a/php-transformer/tests/fixtures/parity/html-autofit-grid-inline-leaf-items.json +++ b/php-transformer/tests/fixtures/parity/html-autofit-grid-inline-leaf-items.json @@ -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, @@ -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" } @@ -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": "

Acme Corp

" } + { "path": "serialized_blocks", "assert": "not_contains", "value": "is-layout-grid" }, + { "path": "serialized_blocks", "assert": "contains", "value": "

Acme Corp

" }, + { "path": "assets.1.content", "assert": "contains", "value": ".client-strip{display:grid;grid-template-columns:repeat(auto-fit, minmax(190px, 1fr));gap:24px}" } ] } diff --git a/php-transformer/tests/fixtures/parity/html-autofit-grid-maps-to-minimum-column-width.json b/php-transformer/tests/fixtures/parity/html-autofit-grid-maps-to-minimum-column-width.json deleted file mode 100644 index a04ba5a4..00000000 --- a/php-transformer/tests/fixtures/parity/html-autofit-grid-maps-to-minimum-column-width.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "schema": "blocks-engine/php-transformer/parity-fixture/v1", - "name": "html-autofit-grid-maps-to-minimum-column-width", - "description": "An author-CSS grid container using repeat(auto-fit, minmax(W, 1fr)) is exactly expressible as native WordPress grid layout. It must become a core/group with layout {type:grid, minimumColumnWidth:W} instead of demoting to the css-owned flow group, which stacks the cards in a single column and loses the responsive multi-column arrangement.", - "source_reference": { - "repo": "php-transformer", - "path": "tests/fixtures/parity/html-autofit-grid-maps-to-minimum-column-width.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: the css-owned demotion dropped the layout attribute and the carried CSS never reproduced the auto-fit tracks." - }, - "legacy_comparison": { - "skip": true, - "reason": "Covers current PHP transformer layout classification behavior; no downstream legacy comparison." - }, - "operation": "html_transformer.transform", - "input": { - "content": "

Ledger

Design system for a fintech team.

Atlas

Mapping tools for field research.

Relay

Realtime dashboard for dispatch.

" - }, - "expected_blocks": [ - { - "path": "blocks.0", - "name": "core/group", - "attrs": { "layout": { "type": "grid", "minimumColumnWidth": "240px" } } - } - ], - "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": "contains", "value": "is-layout-grid" }, - { "path": "serialized_blocks", "assert": "contains", "value": "\"minimumColumnWidth\":\"240px\"" }, - { "path": "serialized_blocks", "assert": "not_contains", "value": "blocks-engine-css-owned-flow" } - ] -} diff --git a/php-transformer/tests/fixtures/parity/html-autofit-grid-stays-css-owned.json b/php-transformer/tests/fixtures/parity/html-autofit-grid-stays-css-owned.json new file mode 100644 index 00000000..5292d58f --- /dev/null +++ b/php-transformer/tests/fixtures/parity/html-autofit-grid-stays-css-owned.json @@ -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": "

Ledger

Design system for a fintech team.

Atlas

Mapping tools for field research.

Relay

Realtime dashboard for dispatch.

" + }, + "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}" } + ] +} diff --git a/php-transformer/tests/fixtures/parity/html-horizontal-flex-stays-columns.json b/php-transformer/tests/fixtures/parity/html-horizontal-flex-stays-columns.json index 5502a54b..db6cd94c 100644 --- a/php-transformer/tests/fixtures/parity/html-horizontal-flex-stays-columns.json +++ b/php-transformer/tests/fixtures/parity/html-horizontal-flex-stays-columns.json @@ -5,7 +5,7 @@ "source_reference": { "repo": "php-transformer", "path": "tests/fixtures/parity/html-horizontal-flex-stays-columns.json", - "notes": "Same display:flex container shape as the vertical fixture but without flex-direction:column. The ColumnsPattern flex-direction guard does not fire, so the horizontal columns layout is preserved unchanged." + "notes": "Same display:flex container shape as the vertical fixture but without flex-direction:column. The ColumnsPattern flex-direction guard does not fire, so the horizontal columns layout is preserved unchanged. The container is demoted to a css-owned core/group, which drops the native layout attribute, so the inline display:flex must ride to the generated stylesheet on a geometry carrier or the two columns stack." }, "legacy_comparison": { "skip": true, @@ -28,7 +28,9 @@ { "path": "status", "assert": "equals", "value": "success" }, { "path": "blocks", "assert": "count", "count": 1 }, { "path": "blocks.0.innerBlocks", "assert": "count", "count": 2 }, - { "path": "serialized_blocks", "assert": "contains", "value": "cols blocks-engine-css-owned-layout" }, - { "path": "serialized_blocks", "assert": "not_contains", "value": "blocks-engine/author-layout" } + { "path": "serialized_blocks", "assert": "contains", "value": "class=\"wp-block-group cols be-inline-geometry-" }, + { "path": "serialized_blocks", "assert": "contains", "value": "blocks-engine-css-owned-layout" }, + { "path": "serialized_blocks", "assert": "not_contains", "value": "blocks-engine/author-layout" }, + { "path": "assets.0.content", "assert": "contains", "value": "{display:flex !important}" } ] } diff --git a/php-transformer/tests/fixtures/parity/html-single-child-flex-svg-address.json b/php-transformer/tests/fixtures/parity/html-single-child-flex-svg-address.json index 63233023..f5032681 100644 --- a/php-transformer/tests/fixtures/parity/html-single-child-flex-svg-address.json +++ b/php-transformer/tests/fixtures/parity/html-single-child-flex-svg-address.json @@ -1,7 +1,7 @@ { "schema": "blocks-engine/php-transformer/parity-fixture/v1", "name": "html-single-child-flex-svg-address", - "description": "Preserves single-child flex wrappers with passive illustrative inline SVG represented as native core/image and readable address line breaks without unsupported fallbacks.", + "description": "Preserves single-child flex wrappers with passive illustrative inline SVG represented as native core/image and readable address line breaks without unsupported fallbacks. The wrapper is demoted to a css-owned core/group, so its inline display:flex and centering must ride to the generated stylesheet on a geometry carrier — dropping them leaves the illustration flush left instead of centered.", "source_reference": { "repo": "php-transformer", "path": "tests/fixtures/parity/html-single-child-flex-svg-address.json", @@ -17,7 +17,7 @@ }, "expected_blocks": [ { "path": "blocks.0", "name": "core/group" }, - { "path": "blocks.0.innerBlocks.0", "name": "core/group", "attrs": { "className": "illustration-frame blocks-engine-css-owned-layout" } }, + { "path": "blocks.0.innerBlocks.0", "name": "core/group" }, { "path": "blocks.0.innerBlocks.0.innerBlocks.0", "name": "core/image" }, { "path": "blocks.0.innerBlocks.1", "name": "core/paragraph", "attrs": { "className": "contact-card", "content": "214 Maple Street
Benton Harbor, MI
(269) 555-0193" } } ], @@ -27,7 +27,10 @@ { "path": "fallbacks", "assert": "count", "count": 0 }, { "path": "blocks.0.innerBlocks.0.innerBlocks.0.attrs.url", "assert": "contains", "value": "assets/materialized-svg/" }, { "path": "blocks.0.innerBlocks.0.innerBlocks.0.attrs.alt", "assert": "equals", "value": "Storefront illustration" }, - { "path": "assets", "assert": "count", "count": 1 }, + { "path": "assets", "assert": "count", "count": 2 }, + { "path": "assets.1.content", "assert": "contains", "value": "{display:flex !important;align-items:center !important;justify-content:center !important}" }, + { "path": "serialized_blocks", "assert": "contains", "value": "class=\"wp-block-group illustration-frame be-inline-geometry-" }, + { "path": "serialized_blocks", "assert": "contains", "value": "blocks-engine-css-owned-layout" }, { "path": "serialized_blocks", "assert": "contains", "value": "Storefront illustration" }, { "path": "serialized_blocks", "assert": "not_contains", "value": "blocks-engine/author-layout" }, { "path": "coverage.0.fallback_count", "assert": "equals", "value": 0 } diff --git a/php-transformer/tests/fixtures/parity/html-single-child-flex-svg.json b/php-transformer/tests/fixtures/parity/html-single-child-flex-svg.json index ffadf039..c791d3a8 100644 --- a/php-transformer/tests/fixtures/parity/html-single-child-flex-svg.json +++ b/php-transformer/tests/fixtures/parity/html-single-child-flex-svg.json @@ -1,7 +1,7 @@ { "schema": "blocks-engine/php-transformer/parity-fixture/v1", "name": "html-single-child-flex-svg", - "description": "Converts a single illustrative SVG inside a flex centering wrapper without leaking failed column-probe fallbacks, materializing the SVG as editable core/image media.", + "description": "Converts a single illustrative SVG inside a flex centering wrapper without leaking failed column-probe fallbacks, materializing the SVG as editable core/image media. The wrapper is demoted to a css-owned core/group, so its inline display:flex and justify-content must ride to the generated stylesheet on a geometry carrier — dropping them leaves the diagram flush left instead of centered.", "source_reference": { "repo": "php-transformer", "path": "tests/fixtures/parity/html-single-child-flex-svg.json", @@ -27,8 +27,10 @@ { "path": "blocks.0.innerBlocks", "assert": "count", "count": 1 }, { "path": "blocks.0.innerBlocks.0.attrs.url", "assert": "contains", "value": "assets/materialized-svg/" }, { "path": "assets.0.content", "assert": "contains", "value": "GIRTH" }, - { "path": "assets", "assert": "count", "count": 1 }, - { "path": "serialized_blocks", "assert": "contains", "value": "wp-block-group blocks-engine-css-owned-layout" }, + { "path": "assets", "assert": "count", "count": 2 }, + { "path": "assets.1.content", "assert": "contains", "value": "{display:flex !important;justify-content:center !important}" }, + { "path": "serialized_blocks", "assert": "contains", "value": "wp-block-group be-inline-geometry-" }, + { "path": "serialized_blocks", "assert": "contains", "value": "blocks-engine-css-owned-layout" }, { "path": "serialized_blocks", "assert": "not_contains", "value": "blocks-engine/author-layout" }, { "path": "fallbacks", "assert": "count", "count": 0 }, { "path": "source_reports.conversion_report.metrics.fallback_count", "assert": "equals", "value": 0 } diff --git a/php-transformer/tests/fixtures/parity/html-vertical-flex-column-becomes-group.json b/php-transformer/tests/fixtures/parity/html-vertical-flex-column-becomes-group.json index 0f405947..5da9f5a1 100644 --- a/php-transformer/tests/fixtures/parity/html-vertical-flex-column-becomes-group.json +++ b/php-transformer/tests/fixtures/parity/html-vertical-flex-column-becomes-group.json @@ -5,7 +5,7 @@ "source_reference": { "repo": "php-transformer", "path": "tests/fixtures/parity/html-vertical-flex-column-becomes-group.json", - "notes": "Keys solely off the CSS flex-direction:column value (never class/fixture names). The author-layout companion preserves source CSS as the sole layout authority." + "notes": "Keys solely off the CSS flex-direction:column value (never class/fixture names). The author-layout companion preserves source CSS as the sole layout authority. The css-owned demotion drops the native layout attribute, so the inline flex declarations ride to the generated stylesheet on a geometry carrier: a flow stack only coincidentally resembles a flex column, and loses the authored gap and any cross-axis alignment." }, "legacy_comparison": { "skip": true, @@ -16,7 +16,7 @@ "content": "
New

Title

Subhead

Go
" }, "expected_blocks": [ - { "path": "blocks.0", "name": "core/group", "attrs": { "className": "hero blocks-engine-css-owned-layout blocks-engine-css-owned-flow" } }, + { "path": "blocks.0", "name": "core/group" }, { "path": "blocks.0.innerBlocks.0", "name": "core/paragraph" }, { "path": "blocks.0.innerBlocks.1", "name": "core/heading", "attrs": { "content": "Title", "level": 1 } }, { "path": "blocks.0.innerBlocks.2", "name": "core/paragraph", "attrs": { "content": "Subhead" } }, @@ -26,7 +26,9 @@ { "path": "status", "assert": "equals", "value": "success" }, { "path": "blocks", "assert": "count", "count": 1 }, { "path": "blocks.0.innerBlocks", "assert": "count", "count": 4 }, - { "path": "serialized_blocks", "assert": "contains", "value": "
" }, + { "path": "serialized_blocks", "assert": "contains", "value": "
" }, + { "path": "assets.0.content", "assert": "contains", "value": "{display:flex !important;flex-direction:column !important;gap:1rem !important}" }, { "path": "serialized_blocks", "assert": "contains", "value": "wp-block-button__link" }, { "path": "serialized_blocks", "assert": "not_contains", "value": "blocks-engine/author-layout" }, { "path": "serialized_blocks", "assert": "not_contains", "value": "wp:columns" } diff --git a/php-transformer/tests/unit/auto-fit-grid-carrier.php b/php-transformer/tests/unit/auto-fit-grid-carrier.php new file mode 100644 index 00000000..14641c4e --- /dev/null +++ b/php-transformer/tests/unit/auto-fit-grid-carrier.php @@ -0,0 +1,141 @@ + (string) ($asset['content'] ?? ''), + array_values(array_filter( + is_array($result['assets'] ?? null) ? $result['assets'] : array(), + static fn (array $asset): bool => $source === ($asset['source'] ?? '') + )) + )); +}; + +$transform = static fn (string $html): array => ( new HtmlTransformer() )->transform($html, array())->toArray(); + +$cards = '

One

Two

Three

'; + +// -- Defect: an inline auto-fit track list is converted to a native grid layout. +$inlineAutoFit = $transform( + '
' . $cards . '
' +); +$inlineAutoFitMarkup = (string) ($inlineAutoFit['serialized_blocks'] ?? ''); +$inlineAutoFitEngineCss = $cssFor($inlineAutoFit, 'engine-support'); + +$assert( + ! str_contains($inlineAutoFitMarkup, 'minimumColumnWidth'), + 'inline auto-fit: no native grid layout attribute, which WordPress would render as auto-fill', + $inlineAutoFitMarkup +); +$assert( + str_contains($inlineAutoFitMarkup, 'blocks-engine-css-owned-grid'), + 'inline auto-fit: container is marked css-owned-grid like every other non-expressible track list', + $inlineAutoFitMarkup +); +$assert( + str_contains($inlineAutoFitEngineCss, 'grid-template-columns:repeat(auto-fit, minmax(280px, 1fr))'), + 'inline auto-fit: the authored track list rides to the generated stylesheet verbatim', + $inlineAutoFitEngineCss +); +$assert( + str_contains($inlineAutoFitEngineCss, 'display:grid'), + 'inline auto-fit: the carrier keeps the container a grid', + $inlineAutoFitEngineCss +); + +// -- Same defect through a class-owned track list, which is the shape the +// site-builder corpus authors. The author stylesheet already retains the rule; +// the native attribute is what overrides it. +$classAutoFit = $transform( + '' + . '
' . $cards . '
' +); +$classAutoFitMarkup = (string) ($classAutoFit['serialized_blocks'] ?? ''); +$classAutoFitAuthorCss = $cssFor($classAutoFit, 'author-css'); + +$assert( + ! str_contains($classAutoFitMarkup, 'minimumColumnWidth'), + 'class-owned auto-fit: no native grid layout attribute', + $classAutoFitMarkup +); +$assert( + ! str_contains($classAutoFitMarkup, 'is-layout-grid'), + 'class-owned auto-fit: no core grid layout classes competing with the author rule', + $classAutoFitMarkup +); +$assert( + str_contains($classAutoFitAuthorCss, '.cards{display:grid;grid-template-columns:repeat(auto-fit, minmax(280px, 1fr));gap:24px}'), + 'class-owned auto-fit: the author rule stays the single owner of the track geometry', + $classAutoFitAuthorCss +); + +// -- Control: auto-fill IS natively expressible and must keep converting. +$inlineAutoFill = $transform( + '
' . $cards . '
' +); +$inlineAutoFillMarkup = (string) ($inlineAutoFill['serialized_blocks'] ?? ''); +$inlineAutoFillEngineCss = $cssFor($inlineAutoFill, 'engine-support'); + +$assert( + str_contains($inlineAutoFillMarkup, '"layout":{"type":"grid","minimumColumnWidth":"280px"}'), + 'auto-fill control: still converts to the native grid layout attribute', + $inlineAutoFillMarkup +); +$assert( + ! str_contains($inlineAutoFillMarkup, 'blocks-engine-css-owned-grid'), + 'auto-fill control: does not fall back to the css-owned-grid carrier', + $inlineAutoFillMarkup +); +$assert( + ! str_contains($inlineAutoFillEngineCss, 'grid-template-columns'), + 'auto-fill control: WordPress still owns the track geometry', + $inlineAutoFillEngineCss +); + +$classAutoFill = $transform( + '' + . '
' . $cards . '
' +); +$classAutoFillMarkup = (string) ($classAutoFill['serialized_blocks'] ?? ''); + +$assert( + str_contains($classAutoFillMarkup, '"layout":{"type":"grid","minimumColumnWidth":"280px"}'), + 'class-owned auto-fill control: still converts to the native grid layout attribute', + $classAutoFillMarkup +); + +if ( $failures > 0 ) { + fwrite(STDERR, "Auto-fit grid carrier contract: {$failures} failed, {$passes} passed\n"); + exit(1); +} + +fwrite(STDOUT, "Auto-fit grid carrier contract passed: {$passes} assertions\n"); diff --git a/php-transformer/tests/unit/block-style-support-conversion.php b/php-transformer/tests/unit/block-style-support-conversion.php index e81a26f3..fec27c7d 100644 --- a/php-transformer/tests/unit/block-style-support-conversion.php +++ b/php-transformer/tests/unit/block-style-support-conversion.php @@ -82,7 +82,7 @@ $assert(! isset($groupAttrs['style']['spacing']['blockGap']), '15: core group save omits block gap without a core layout attribute', json_encode($groupAttrs)); $assert(str_contains($groupInnerHtml, 'min-height:100svh'), '16: core group retains supported dimensions', $groupInnerHtml); -$nativeGridHtml = '

Fallback card

'; +$nativeGridHtml = '

Fallback card

'; $nativeGridResult = ( new HtmlTransformer() )->transform($nativeGridHtml, array())->toArray(); $nativeGrid = $nativeGridResult['blocks'][0] ?? array(); $nativeGridAttrs = is_array($nativeGrid['attrs'] ?? null) ? $nativeGrid['attrs'] : array(); @@ -94,6 +94,17 @@ $assert(! str_contains($nativeGridMarkup, 'gap:1.2rem'), '16c: native Group grid markup omits inline gap that Gutenberg save does not reproduce', $nativeGridMarkup); $assert(str_contains($nativeGridCss, 'gap:1.2rem !important'), '16d: inline native Group grid gap moves to the generated geometry carrier', $nativeGridCss); +// The same shape authored with auto-fit is NOT natively expressible: core's +// layout support hardcodes auto-fill, which retains the tracks auto-fit +// collapses. See tests/unit/auto-fit-grid-carrier.php for the full contract. +$autoFitGridResult = ( new HtmlTransformer() )->transform( + '

Fallback card

', + array() +)->toArray(); +$autoFitGridAttrs = is_array($autoFitGridResult['blocks'][0]['attrs'] ?? null) ? $autoFitGridResult['blocks'][0]['attrs'] : array(); + +$assert(! isset($autoFitGridAttrs['layout']), '16e: the same shape authored with auto-fit stays under CSS ownership', json_encode($autoFitGridAttrs)); + $unorderedListSource = '
  • Alpha
'; $unorderedListResult = ( new HtmlTransformer() )->transform($unorderedListSource)->toArray(); $unorderedListAttrs = $unorderedListResult['blocks'][0]['attrs'] ?? array(); diff --git a/php-transformer/tests/unit/button-font-family-carry.php b/php-transformer/tests/unit/button-font-family-carry.php new file mode 100644 index 00000000..6c31484d --- /dev/null +++ b/php-transformer/tests/unit/button-font-family-carry.php @@ -0,0 +1,144 @@ +nativeAttributes($authoredButtonCss); +$expected = array( + 'style' => array( + 'color' => array( 'background' => '#135e96', 'text' => '#ffffff' ), + 'border' => array( 'width' => '2px', 'style' => 'solid', 'color' => '#0b3d6b', 'radius' => '6px' ), + 'spacing' => array( 'padding' => array( 'top' => '10px', 'right' => '16px', 'bottom' => '10px', 'left' => '16px' ) ), + 'typography' => array( + 'fontFamily' => '"Trebuchet MS", "Segoe UI", sans-serif', + 'fontSize' => '0.88rem', + 'fontWeight' => '700', + 'letterSpacing' => '0.08em', + 'textTransform' => 'uppercase', + ), + ), +); + +$assert( + '"Trebuchet MS", "Segoe UI", sans-serif' === (string) ($resolved['style']['typography']['fontFamily'] ?? ''), + 'resolver: the authored font-family reaches style.typography.fontFamily', + json_encode($resolved['style']['typography'] ?? null) +); +$assert( + json_encode($expected) === json_encode($resolved), + 'resolver: every carried declaration maps exactly, with font-family in canonical order', + json_encode($resolved) +); + +// -- A button whose rule authors no font-family gains none. +$withoutFamily = ( new ButtonStyleResolver() )->nativeAttributes('background:#135e96;color:#ffffff;font-size:0.88rem'); + +$assert( + ! isset($withoutFamily['style']['typography']['fontFamily']), + 'resolver: no font-family is invented when the rule authors none', + json_encode($withoutFamily) +); +$assert( + array( 'fontSize' => '0.88rem' ) === ($withoutFamily['style']['typography'] ?? null), + 'resolver: the remaining typography subset is untouched', + json_encode($withoutFamily['style']['typography'] ?? null) +); + +// -- G9 end to end: the shape from the report, where `.btn` is consumed into +// block attributes and only the block attribute can still carry the typeface. +$result = ( new HtmlTransformer() )->transform( + '' + . '', + array() +)->toArray(); +$button = $result['blocks'][0]['innerBlocks'][0] ?? array(); +$buttonTypography = $button['attrs']['style']['typography'] ?? null; +$serialized = (string) ($result['serialized_blocks'] ?? ''); + +$assert( + 'core/button' === ($button['blockName'] ?? ''), + 'end to end: the authored anchor still becomes a core/button', + (string) ($button['blockName'] ?? '(none)') +); +$assert( + '"Trebuchet MS", "Segoe UI", sans-serif' === (string) ($buttonTypography['fontFamily'] ?? ''), + 'end to end: the block attributes carry the authored typeface', + json_encode($buttonTypography) +); +$assert( + str_contains($serialized, 'font-family:"Trebuchet MS", "Segoe UI", sans-serif') + || str_contains($serialized, 'font-family:"Trebuchet MS", "Segoe UI", sans-serif'), + 'end to end: the rendered button link declares the authored typeface inline, where theme.json cannot outrank it', + $serialized +); +$assert( + array( + 'fontFamily' => '"Trebuchet MS", "Segoe UI", sans-serif', + 'fontSize' => '0.88rem', + 'fontWeight' => '700', + 'letterSpacing' => '0.08em', + 'textTransform' => 'uppercase', + ) === $buttonTypography, + 'end to end: the other carried typography declarations are unchanged', + json_encode($buttonTypography) +); + +if ( $failures > 0 ) { + fwrite(STDERR, "Button font-family carry contract: {$failures} failed, {$passes} passed\n"); + exit(1); +} + +fwrite(STDOUT, "Button font-family carry contract passed: {$passes} assertions\n"); diff --git a/php-transformer/tests/unit/css-owned-flex-carrier.php b/php-transformer/tests/unit/css-owned-flex-carrier.php new file mode 100644 index 00000000..bfb33fb7 --- /dev/null +++ b/php-transformer/tests/unit/css-owned-flex-carrier.php @@ -0,0 +1,154 @@ + (string) ($asset['content'] ?? ''), + array_values(array_filter( + is_array($result['assets'] ?? null) ? $result['assets'] : array(), + static fn (array $asset): bool => $source === ($asset['source'] ?? '') + )) + )); +}; + +$transform = static fn (string $html): array => ( new HtmlTransformer() )->transform($html, array())->toArray(); + +/** Rule body for the geometry carrier class present in the markup. */ +$carrierRule = static function (string $markup, string $css): string { + if ( 1 !== preg_match('/\b(be-inline-geometry-[a-f0-9-]+)\b/', $markup, $match) ) { + return ''; + } + if ( 1 !== preg_match('/(?

Alpha

Beta

Gamma

'; + +// -- Defect: a css-owned-flow footer loses its inline display:flex entirely. +$flex = $transform( + '
' . $columns . '
' +); +$flexMarkup = (string) ($flex['serialized_blocks'] ?? ''); +$flexEngineCss = $cssFor($flex, 'engine-support'); +$flexRule = $carrierRule($flexMarkup, $flexEngineCss); + +$assert( + str_contains($flexMarkup, 'blocks-engine-css-owned-flow'), + 'inline flex: the container is still demoted to a css-owned-flow group', + $flexMarkup +); +$assert( + 1 === preg_match('/\bbe-inline-geometry-[a-f0-9-]+\b/', $flexMarkup), + 'inline flex: the demoted container receives a geometry carrier class', + $flexMarkup +); +$assert( + str_contains($flexRule, 'display:flex'), + 'inline flex: the carrier keeps the container a flex container', + '' !== $flexRule ? $flexRule : $flexEngineCss +); +$assert( + str_contains($flexRule, 'gap:2rem'), + 'inline flex: the authored gap rides with the display declaration', + '' !== $flexRule ? $flexRule : $flexEngineCss +); +$assert( + str_contains($flexRule, 'justify-content:space-between'), + 'inline flex: the authored main-axis distribution rides with the display declaration', + '' !== $flexRule ? $flexRule : $flexEngineCss +); +$assert( + str_contains($flexRule, 'align-items:center'), + 'inline flex: the authored cross-axis alignment rides with the display declaration', + '' !== $flexRule ? $flexRule : $flexEngineCss +); + +// -- A vertical flex column keeps its direction rather than silently becoming a row. +$column = $transform( + '
' . $columns . '
' +); +$columnMarkup = (string) ($column['serialized_blocks'] ?? ''); +$columnRule = $carrierRule($columnMarkup, $cssFor($column, 'engine-support')); + +$assert( + str_contains($columnRule, 'display:flex') && str_contains($columnRule, 'flex-direction:column') && str_contains($columnRule, 'flex-wrap:wrap'), + 'inline flex column: direction and wrap ride with the display declaration', + '' !== $columnRule ? $columnRule : $cssFor($column, 'engine-support') +); + +// -- Control: no authored display gains no carrier and no flex declaration. +$plain = $transform('
' . $columns . '
'); +$plainMarkup = (string) ($plain['serialized_blocks'] ?? ''); +$plainEngineCss = $cssFor($plain, 'engine-support'); + +$assert( + 1 !== preg_match('/\bbe-inline-geometry-[a-f0-9-]+\b/', $plainMarkup), + 'no authored display control: no geometry carrier class is invented', + $plainMarkup +); +$assert( + ! str_contains($plainEngineCss, 'display:flex'), + 'no authored display control: no flex declaration is invented', + $plainEngineCss +); +$assert( + str_starts_with($plainMarkup, '