diff --git a/php-transformer/src/HtmlToBlocks/BlockFactory.php b/php-transformer/src/HtmlToBlocks/BlockFactory.php index a3226c9d..3e3fdab7 100644 --- a/php-transformer/src/HtmlToBlocks/BlockFactory.php +++ b/php-transformer/src/HtmlToBlocks/BlockFactory.php @@ -20,6 +20,54 @@ final class BlockFactory */ private const GROUP_TAG_NAMES = array( 'div', 'header', 'nav', 'section', 'article', 'aside', 'footer', 'main' ); + /** + * Blocks whose supports.layout accepts an authored layout attribute, per + * the vendored block-library block.json files. Blocks declaring + * layout {allowEditing:false} (quote, details, accordion items) manage + * their own layout and never accept one; blocks without layout support + * (list, paragraph, image) reject the attribute entirely. + * + * @var array + */ + private const LAYOUT_SUPPORTING_BLOCKS = array( + 'core/accordion' => true, + 'core/buttons' => true, + 'core/column' => true, + 'core/comments-pagination' => true, + 'core/cover' => true, + 'core/group' => true, + 'core/navigation' => true, + 'core/post-content' => true, + 'core/post-template' => true, + 'core/query' => true, + 'core/query-pagination' => true, + 'core/social-links' => true, + 'core/tab-list' => true, + 'core/tab-panel' => true, + 'core/term-template' => true, + 'core/terms-query' => true, + ); + + /** + * The subset whose supports.layout permits switching to type grid + * (layout true, or an object without an allowSwitching:false pin to a + * fixed flex default). + * + * @var array + */ + private const GRID_LAYOUT_BLOCKS = array( + 'core/accordion' => true, + 'core/column' => true, + 'core/cover' => true, + 'core/group' => true, + 'core/post-content' => true, + 'core/post-template' => true, + 'core/query' => true, + 'core/tab-panel' => true, + 'core/term-template' => true, + 'core/terms-query' => true, + ); + private ?StyleAttributeMapper $styleMapper = null; private function styleMapper(): StyleAttributeMapper @@ -64,6 +112,19 @@ private function normalizeAttrsForBlock(string $name, array $attrs): array { $attrs = $this->normalizeClassNameAttr($attrs); + // Layout is a block-supports opt-in. Stamping it on a block whose + // supports do not accept an authored layout bakes is-layout-* classes + // into save markup the block's canonical save never emits, so + // downstream re-serialization rejects the block and reverts it. + if ( isset($attrs['layout']) ) { + $layoutType = is_array($attrs['layout']) ? strtolower((string) ($attrs['layout']['type'] ?? '')) : ''; + if ( ! isset(self::LAYOUT_SUPPORTING_BLOCKS[$name]) + || ( 'grid' === $layoutType && ! isset(self::GRID_LAYOUT_BLOCKS[$name]) ) + ) { + unset($attrs['layout']); + } + } + // These core save functions do not reproduce dimensions.maxWidth. Inline // max-width is retained by the generated geometry carrier stylesheet. if ( in_array($name, array( 'core/group', 'core/column', 'core/columns', 'core/image', 'core/list-item', 'core/media-text', 'core/paragraph', 'core/separator' ), true) ) { @@ -106,7 +167,11 @@ private function normalizeAttrsForBlock(string $name, array $attrs): array } } - if ( in_array($name, array( 'core/buttons', 'core/column', 'core/columns', 'core/group', 'core/heading', 'core/list', 'core/list-item', 'core/media-text', 'core/paragraph' ), true) ) { + if ( in_array($name, array( 'core/buttons', 'core/column', 'core/columns', 'core/group', 'core/heading', 'core/list', 'core/list-item', 'core/media-text', 'core/paragraph' ), true) + // A native grid layout renders its gap from blockGap; stripping it + // would substitute the theme default for the source grid gap. + && ! ( 'core/group' === $name && 'grid' === (string) ($attrs['layout']['type'] ?? '') ) + ) { unset($attrs['style']['spacing']['blockGap']); if ( empty($attrs['style']['spacing']) ) { unset($attrs['style']['spacing']); diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index 5acd399e..bea9a7b0 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -395,6 +395,32 @@ final class HtmlTransformer private const CSS_OWNED_FLOW_CLASS = 'blocks-engine-css-owned-flow'; + private const CSS_OWNED_GRID_CLASS = 'blocks-engine-css-owned-grid'; + + /** @var list Inline grid declarations carried to the generated stylesheet for css-owned grids. */ + private const CSS_OWNED_GRID_CARRIER_PROPERTIES = array( + 'display', + 'grid', + 'grid-template', + 'grid-template-areas', + 'grid-template-columns', + 'grid-template-rows', + 'grid-auto-flow', + 'grid-auto-columns', + 'grid-auto-rows', + 'gap', + 'row-gap', + 'column-gap', + 'grid-row-gap', + 'grid-column-gap', + 'align-content', + 'align-items', + 'justify-content', + 'justify-items', + 'place-content', + 'place-items', + ); + 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. */ @@ -692,6 +718,16 @@ public function transform(string $html, array $options = array()): TransformerRe $semanticParityReport, $contentRoundTripReport ); + $headMetadata = $this->headMetadataReport($html); + if ( array() !== $headMetadata ) { + $diagnostics[] = array( + 'code' => 'html_head_metadata_not_carried', + 'message' => 'Named head metadata (meta description and social property tags) is not representable in block markup; the entries are surfaced in source_reports.head_metadata for the destination document to adopt deliberately.', + 'source' => self::class, + 'severity' => 'info', + 'entries' => $headMetadata, + ); + } $authorLayoutTopologyFindings = $this->authorLayoutTopologyFindings(); foreach ( $authorLayoutTopologyFindings as $finding ) { $diagnostics[] = array( @@ -722,6 +758,7 @@ public function transform(string $html, array $options = array()): TransformerRe $sourceReports = array( 'native_target_blocks' => $nativeTargetBlocks, 'available_core_blocks' => $nativeTargetBlocks, + 'head_metadata' => $headMetadata, 'runtime_islands' => $this->runtimeIslands, 'generated_blocks' => $this->generatedBlocks, 'gutenberg_gaps' => $this->descriptionListBlockGenerated ? array( @@ -878,6 +915,52 @@ private function metrics(string $input, array $blocks, string $output, array $fa ); } + /** + * Named head metadata (meta description, social property tags) has no + * block-markup representation. Surface the entries so consumers can carry + * them to the destination document deliberately instead of reading the + * strip as a malformed design. Mechanical entries (charset, viewport) + * belong to the destination document and are not reported. + * + * @return array> + */ + private function headMetadataReport(string $html): array + { + if ( ! preg_match('/]/i', $html) ) { + return array(); + } + + $document = new DOMDocument(); + $previous = libxml_use_internal_errors(true); + $loaded = $document->loadHTML('' . $html); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + $head = $loaded ? $document->getElementsByTagName('head')->item(0) : null; + if ( ! $head instanceof DOMElement ) { + return array(); + } + + $entries = array(); + foreach ( $head->getElementsByTagName('meta') as $meta ) { + if ( ! $meta instanceof DOMElement ) { + continue; + } + $content = trim($meta->getAttribute('content')); + $name = strtolower(trim($meta->getAttribute('name'))); + $property = strtolower(trim($meta->getAttribute('property'))); + if ( '' === $content || ( '' === $name && '' === $property ) || 'viewport' === $name ) { + continue; + } + $entries[] = array_filter(array( + 'name' => $name, + 'property' => $property, + 'content' => substr($content, 0, 500), + ), static fn (string $value): bool => '' !== $value); + } + + return array_slice($entries, 0, 20); + } + private function materializeAuthorStylesheet(string $html, string $staticCss, bool $includeAuthorStyles = true, string $serializedBlocks = ''): void { $cssParts = array(); @@ -927,6 +1010,12 @@ private function materializeAuthorStylesheet(string $html, string $staticCss, bo // This precedes author CSS so source child margins remain authoritative. $cssParts[] = ':where(.wp-block-group.' . self::CSS_OWNED_FLOW_CLASS . ')>*{margin-block-start:0;margin-block-end:0}'; } + if ( str_contains($serializedBlocks, self::CSS_OWNED_GRID_CLASS) ) { + // Core flow margins are not part of a source grid contract; the + // carried grid geometry (gap) owns the spacing between items. The + // carrier rides groups and lists, so the reset is class-scoped. + $cssParts[] = ':where(.' . self::CSS_OWNED_GRID_CLASS . ')>*{margin-block-start:0;margin-block-end:0}'; + } if ( str_contains($serializedBlocks, self::CSS_OWNED_LAYOUT_ITEM_CLASS) ) { // A semantic Group used as a direct grid/flex item contains native // paragraph blocks. Neutralize only those generated inner defaults. @@ -2506,7 +2595,13 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca return null; } - return $this->createBlock('core/list', array_merge($this->presentationAttributes($element), 'ol' === $tagName ? array( 'ordered' => true ) : array()), $items, $element); + // core/list has no layout support, so an author grid on the list + // element rides the css-owned grid carrier instead of a layout attr. + $listAttrs = $this->isCssOwnedGridElement($element) + ? $this->cssOwnedGridAttributes($element) + : $this->presentationAttributes($element); + + return $this->createBlock('core/list', array_merge($listAttrs, 'ol' === $tagName ? array( 'ordered' => true ) : array()), $items, $element); } if ( 'dl' === $tagName ) { @@ -2522,7 +2617,11 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca $items = $this->definitionListItems($element); if ( array() !== $items ) { - return $this->createBlock('core/list', $this->presentationAttributes($element), $items, $element); + $definitionListAttrs = $this->isCssOwnedGridElement($element) + ? $this->cssOwnedGridAttributes($element) + : $this->presentationAttributes($element); + + return $this->createBlock('core/list', $definitionListAttrs, $items, $element); } $children = $this->convertChildren($element, $fallbacks, true); @@ -3955,6 +4054,42 @@ private function authorLayoutBlockFromElement(DOMElement $element, array &$fallb private function cssOwnedGroupAttributes(DOMElement $element): array { $attrs = $this->presentationAttributes($element); + $layout = $attrs['layout'] ?? null; + if ( is_array($layout) && 'grid' === (string) ($layout['type'] ?? '') && '' !== (string) ($layout['minimumColumnWidth'] ?? '') ) { + // The source track list is exactly expressible as native grid + // layout, so WordPress owns the geometry and no css-owned + // demotion is needed. The author gap and container background ride + // on block supports so hairline-divider grids (gap:1px plus a + // background painting through the gaps) survive even without the + // materialized author stylesheet. + $declarations = $this->structuralPresentationDeclarations($element); + $style = is_array($attrs['style'] ?? null) ? $attrs['style'] : array(); + $gap = trim((string) ($declarations['gap'] ?? '')); + if ( 1 === preg_match('/^(?:0|[0-9]*\.?[0-9]+(?:px|rem|em|ch|ex|vw|vh|vmin|vmax|%))$/i', $gap) + && ! isset($style['spacing']['blockGap']) + && ! $this->hasConditionalStyleFamily($element, 'layout') + ) { + $style['spacing'] = array_merge(is_array($style['spacing'] ?? null) ? $style['spacing'] : array(), array( 'blockGap' => $gap )); + } + $background = trim((string) ($declarations['background-color'] ?? $declarations['background'] ?? '')); + if ( 1 === preg_match('/^(#[0-9a-f]{3,8}|[a-z][a-z-]*|(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|var)\([^()]*\))$/i', $background) + && ! in_array(strtolower($background), array( 'none', 'inherit', 'initial', 'unset', 'revert', 'revert-layer' ), true) + && ! isset($style['color']['background']) + && ! $this->hasConditionalStyleFamily($element, 'background') + ) { + $style['color'] = array_merge(is_array($style['color'] ?? null) ? $style['color'] : array(), array( 'background' => $background )); + } + if ( array() !== $style ) { + $attrs['style'] = $style; + } + + return $attrs; + } + + if ( $this->isCssOwnedGridElement($element) ) { + return $this->cssOwnedGridAttributes($element); + } + unset($attrs['layout']); $attrs['className'] = $this->mergeClassNames( (string) ($attrs['className'] ?? ''), @@ -3975,6 +4110,46 @@ private function cssOwnedGroupAttributes(DOMElement $element): array return $attrs; } + private function isCssOwnedGridElement(DOMElement $element): bool + { + $display = strtolower(trim((string) preg_replace( + '/\s*!important\s*$/i', + '', + (string) ($this->structuralPresentationDeclarations($element)['display'] ?? '') + ))); + + return in_array($display, array( 'grid', 'inline-grid' ), true); + } + + /** + * Attributes for a block hosting an author grid that WordPress layout + * cannot express — asymmetric tracks on groups, or any grid on blocks + * without grid layout support (core/list). The geometry stays under CSS + * ownership: inline grid declarations ride to the generated stylesheet on + * a carrier class, class-owned ones stay retained by author stylesheet + * materialization. A flow demotion would drop the tracks and stack the + * items vertically. + * + * @return array + */ + private function cssOwnedGridAttributes(DOMElement $element): array + { + // 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. + $inlineDeclarations = $this->cssDeclarations($this->attr($element, 'style')); + $carriedProperties = array_values(array_intersect(self::CSS_OWNED_GRID_CARRIER_PROPERTIES, array_keys($inlineDeclarations))); + $attrs = $this->presentationAttributes($element, array(), $carriedProperties); + unset($attrs['layout']); + $attrs['className'] = $this->mergeClassNames( + (string) ($attrs['className'] ?? ''), + self::CSS_OWNED_LAYOUT_CLASS, + self::CSS_OWNED_GRID_CLASS + ); + + return $attrs; + } + private function authorOwnsChildFlowSpacing(DOMElement $element): bool { $declarations = $this->structuralPresentationDeclarations($element); diff --git a/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php b/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php index 862b5d1f..7c61ca8d 100644 --- a/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php +++ b/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php @@ -357,7 +357,7 @@ private function inlineGeometryClassName( if ( in_array($property, array( 'background', 'background-image' ), true) ) { $value = CssUrlRewriter::rewrite($value, fn (string $url): string => $this->resolvedAssetImageUrl($url)); } - if ('' !== $value && ! preg_match('/[{}<>;]/', $value)) { + if ('' !== $value && ! preg_match('~[{}<>;]|/\*~', $value)) { $geometry[$property] = $value; } } @@ -372,7 +372,13 @@ private function inlineGeometryClassName( return ''; } - ksort($geometry); + // Emit carried declarations in source order: with per-declaration + // !important, last-write-wins is decided by rule order, and an + // alphabetical sort silently flips shorthand/longhand winners + // (grid vs grid-template-columns, gap vs column-gap). Values not + // present inline (forced/custom-property fallbacks) sort last. + $sourceOrder = array_flip(array_keys($declarations)); + uksort($geometry, static fn (string $a, string $b): int => (($sourceOrder[$a] ?? PHP_INT_MAX) <=> ($sourceOrder[$b] ?? PHP_INT_MAX)) ?: strcmp($a, $b)); $declarations = array(); foreach ($geometry as $property => $value) { // A converted inline declaration must continue to outrank authored @@ -1515,11 +1521,24 @@ private function selectorCarriesPseudoState(string $selector): bool private function presentationClassName(string $className): string { $classes = preg_split('/\s+/', trim($className)) ?: array(); - $classes = array_filter($classes, static fn (string $class): bool => '' !== $class && ! self::isBehaviorHookClassName($class) && ! self::isGeneratedCoreClassName($class)); + $classes = array_filter($classes, static fn (string $class): bool => '' !== $class && ! self::isBehaviorHookClassName($class) && ! self::isGeneratedCoreClassName($class) && ! self::isTransformerMarkerClassName($class)); return implode(' ', array_values(array_unique($classes))); } + /** + * Transformer-generated marker and carrier classes found in SOURCE markup + * (re-ingested transformer output) must be re-derived, not preserved as + * author classes: a preserved css-owned-grid marker would trip the + * grid-class heuristics and the carried margin reset. Emitted classNames + * are unaffected — this filters ingestion only. + */ + private static function isTransformerMarkerClassName(string $className): bool + { + return str_starts_with($className, 'blocks-engine-') + || str_starts_with($className, 'be-inline-geometry-'); + } + private static function isBehaviorHookClassName(string $className): bool { return 1 === preg_match('/^js(?:$|[-_:]|[A-Z])/', $className); @@ -1583,6 +1602,12 @@ private function layoutAttribute(DOMElement $element, string $mergedStyle = ''): return array( 'type' => 'flex' ); } if ( preg_match('/(?:^|;)\s*display\s*:\s*(inline-)?grid\b/', $style) ) { + $minimumColumnWidth = $this->autoRepeatMinimumColumnWidth( + (string) ($mergedDeclarations['grid-template-columns'] ?? $inlineDeclarations['grid-template-columns'] ?? '') + ); + if ( '' !== $minimumColumnWidth ) { + return array( 'type' => 'grid', 'minimumColumnWidth' => $minimumColumnWidth ); + } if ( ! preg_match('/(?:^|;)\s*display\s*:\s*(inline-)?grid\b/', $inlineStyle) && $this->hasOwnStyleHook($element) ) { return array(); } @@ -1646,6 +1671,24 @@ private function layoutFlexWrap(string $value): string return in_array($value, array( 'wrap', 'nowrap' ), true) ? $value : ''; } + /** + * A track list of exactly repeat(auto-fit|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. + */ + 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) + && 0.0 < (float) $matches[1] + ) { + return strtolower($matches[1]); + } + + return ''; + } + /** * Unambiguous grid class tokens: a bare `grid`, a numbered `grid-N`, or any * `*-grid` / `*_grid` suffix (footer-grid, card-grid, mission-grid, …) plus @@ -1656,13 +1699,25 @@ private function layoutFlexWrap(string $value): string */ private function hasExplicitGridClass(DOMElement $element): bool { - $className = strtolower($this->attr($element, 'class')); + $className = $this->authorClassTokens($element); return (bool) preg_match('/(?:^|[\s_-])(?:grid|grid-[0-9]+|grid-cols(?:-[0-9]+)?|grid-columns|[a-z0-9]+[-_]grid)(?:$|[\s_-])/', $className); } private function hasGridLikeClass(DOMElement $element): bool { - $className = strtolower($this->attr($element, 'class')); + $className = $this->authorClassTokens($element); return (bool) preg_match('/(?:^|[\s_-])(?:cards|features|services|providers|testimonials|resources|posts|projects|stats|badges|grid|grid-[0-9]+|tiles|collection|gallery)(?:$|[\s_-])/', $className); } + + /** + * Class tokens with generated markers filtered out, so transformer-emitted + * classes (blocks-engine-css-owned-grid, …) re-ingested from prior output + * never trip the author grid-class heuristics. + */ + private function authorClassTokens(DOMElement $element): string + { + $tokens = preg_split('/\s+/', strtolower(trim($this->attr($element, 'class')))) ?: array(); + + return implode(' ', array_filter($tokens, static fn (string $token): bool => '' !== $token && ! GeneratedGutenbergClassPolicy::isGeneratedClassName($token) && ! self::isTransformerMarkerClassName($token))); + } } diff --git a/php-transformer/tests/contract/empty-visual-figure.php b/php-transformer/tests/contract/empty-visual-figure.php index db21f60f..3503e1d5 100644 --- a/php-transformer/tests/contract/empty-visual-figure.php +++ b/php-transformer/tests/contract/empty-visual-figure.php @@ -47,7 +47,7 @@ $inlineMarkup = (string) ($inlineCompiled['serialized_blocks'] ?? ''); $inlineValidity = ( new BlockValidityValidator() )->validateBlocks($inlineCompiled['blocks'] ?? array()); $inlineCssAssets = implode("\n", array_map(static fn (array $asset): string => (string) ($asset['content'] ?? ''), $inlineCompiled['assets'] ?? array())); -$assert(2 === substr_count($inlineMarkup, 'wp-block-group photo') && str_contains($inlineMarkup, 'min-height:var(--h)') && ! str_contains($inlineMarkup, '--a:') && str_contains($inlineCssAssets, '--a:#27485f !important;--b:#87d8ff !important;--h:280px !important') && str_contains($inlineCssAssets, '--a:#6f493e !important;--b:#ff8762 !important;--h:390px !important'), 'Artifact compiler carries fixture87 gallery custom properties in generated CSS while core owns the saved style attribute.'); +$assert(2 === substr_count($inlineMarkup, 'wp-block-group photo') && str_contains($inlineMarkup, 'min-height:var(--h)') && ! str_contains($inlineMarkup, '--a:') && str_contains($inlineCssAssets, '--h:280px !important;--a:#27485f !important;--b:#87d8ff !important') && str_contains($inlineCssAssets, '--h:390px !important;--a:#6f493e !important;--b:#ff8762 !important'), 'Artifact compiler carries fixture87 gallery custom properties in generated CSS while core owns the saved style attribute.'); $assert(! str_contains($inlineMarkup, '--tone:') && str_contains($inlineCssAssets, '--tone:#315b74 !important') && 'pass' === ($inlineValidity['status'] ?? ''), 'Fixture87 card custom paint survives in a generated carrier class without diverging from core style serialization.'); $assert(! str_contains($inlineMarkup, 'class="wp-block-group empty'), 'Final native blocks retain the pseudo paint contract while nonvisual empty figures remain pruned.'); diff --git a/php-transformer/tests/contract/wordpress-site-plan.php b/php-transformer/tests/contract/wordpress-site-plan.php index 6342f9c7..4f277be8 100644 --- a/php-transformer/tests/contract/wordpress-site-plan.php +++ b/php-transformer/tests/contract/wordpress-site-plan.php @@ -83,7 +83,7 @@ $authorLayoutPlan = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => array('index.html' => '
One
Two
Three
Four
Five
')))->toArray()['source_reports']['wordpress_site_plan'] ?? array(); $authorLayoutMarkup = (string) (($authorLayoutPlan['pages'][0]['canonical_block_markup'] ?? '')); $authorLayoutAssets = implode("\n", array_map(static fn (array $asset): string => (string) ($asset['content'] ?? ''), $authorLayoutPlan['assets'] ?? array())); -$assert(str_contains($authorLayoutMarkup, 'wp-block-group ex-row blocks-engine-css-owned-layout blocks-engine-css-owned-flow') && str_contains($authorLayoutMarkup, '
compile(array('entrypoint' => 'index.html', 'files' => array('index.html' => '
', 'about.html' => '
About
')))->toArray()['source_reports']['wordpress_site_plan'] ?? array(); $authorLayoutRouteMarkup = (string) ($authorLayoutRoutePlan['pages'][0]['canonical_block_markup'] ?? ''); $assert(str_contains($authorLayoutRouteMarkup, '"url":"/about"') && ! str_contains($authorLayoutRouteMarkup, '"sourceAttributes":{"href"') && ! str_contains($authorLayoutRouteMarkup, 'href="about.html"'), 'Author-layout anchors expose local routes as first-class URLs in canonical site plans.'); diff --git a/php-transformer/tests/fixtures/parity/artifact-inline-style-extraction-base64.json b/php-transformer/tests/fixtures/parity/artifact-inline-style-extraction-base64.json index 55512a7a..c60c5f5f 100644 --- a/php-transformer/tests/fixtures/parity/artifact-inline-style-extraction-base64.json +++ b/php-transformer/tests/fixtures/parity/artifact-inline-style-extraction-base64.json @@ -25,7 +25,9 @@ }, "expect": [ { "path": "status", "assert": "equals", "value": "success" }, - { "path": "assets", "assert": "count", "count": 1 }, + { "path": "assets", "assert": "count", "count": 2 }, + { "path": "assets.1.path", "assert": "equals", "value": "assets/css/source-author-46b87bf1ffe16a17.css" }, + { "path": "assets.1.content", "assert": "contains", "value": ":where(.blocks-engine-css-owned-grid)>*{margin-block-start:0;margin-block-end:0}" }, { "path": "assets.0.path", "assert": "equals", "value": "index.inline.css" }, { "path": "assets.0.kind", "assert": "equals", "value": "css" }, { "path": "assets.0.role", "assert": "equals", "value": "stylesheet" }, diff --git a/php-transformer/tests/fixtures/parity/artifact-inline-style-extraction.json b/php-transformer/tests/fixtures/parity/artifact-inline-style-extraction.json index b665f089..b5c37f45 100644 --- a/php-transformer/tests/fixtures/parity/artifact-inline-style-extraction.json +++ b/php-transformer/tests/fixtures/parity/artifact-inline-style-extraction.json @@ -25,7 +25,9 @@ }, "expect": [ { "path": "status", "assert": "equals", "value": "success" }, - { "path": "assets", "assert": "count", "count": 1 }, + { "path": "assets", "assert": "count", "count": 2 }, + { "path": "assets.1.path", "assert": "equals", "value": "assets/css/source-author-46b87bf1ffe16a17.css" }, + { "path": "assets.1.content", "assert": "contains", "value": ":where(.blocks-engine-css-owned-grid)>*{margin-block-start:0;margin-block-end:0}" }, { "path": "assets.0.path", "assert": "equals", "value": "index.inline.css" }, { "path": "assets.0.kind", "assert": "equals", "value": "css" }, { "path": "assets.0.role", "assert": "equals", "value": "stylesheet" }, diff --git a/php-transformer/tests/fixtures/parity/artifact-linked-css-layout-wrapper-style-signals.json b/php-transformer/tests/fixtures/parity/artifact-linked-css-layout-wrapper-style-signals.json index f89606eb..363ba1dd 100644 --- a/php-transformer/tests/fixtures/parity/artifact-linked-css-layout-wrapper-style-signals.json +++ b/php-transformer/tests/fixtures/parity/artifact-linked-css-layout-wrapper-style-signals.json @@ -34,13 +34,13 @@ { "path": "source_reports.wordpress_site_plan.diagnostics.0.code", "assert": "equals", "value": "author_layout_topology_changed" }, { "path": "serialized_blocks", "assert": "contains", "value": "hero-grid" }, { "path": "blocks.0.innerBlocks.0.blockName", "assert": "equals", "value": "core/group" }, - { "path": "serialized_blocks", "assert": "contains", "value": "" }, - { "path": "serialized_blocks", "assert": "contains", "value": "
" }, + { "path": "serialized_blocks", "assert": "contains", "value": "" }, + { "path": "serialized_blocks", "assert": "contains", "value": "
" }, { "path": "serialized_blocks", "assert": "not_contains", "value": "" }, - { "path": "serialized_blocks", "assert": "contains", "value": "
" }, + { "path": "serialized_blocks", "assert": "contains", "value": "" }, + { "path": "serialized_blocks", "assert": "contains", "value": "
" }, { "path": "blocks.0.innerBlocks.2.blockName", "assert": "equals", "value": "core/group" }, { "path": "serialized_blocks", "assert": "contains", "value": "" }, { "path": "serialized_blocks", "assert": "contains", "value": "
" }, diff --git a/php-transformer/tests/fixtures/parity/html-asymmetric-grid-css-owned-grid-carrier.json b/php-transformer/tests/fixtures/parity/html-asymmetric-grid-css-owned-grid-carrier.json new file mode 100644 index 00000000..72aeea7d --- /dev/null +++ b/php-transformer/tests/fixtures/parity/html-asymmetric-grid-css-owned-grid-carrier.json @@ -0,0 +1,32 @@ +{ + "schema": "blocks-engine/php-transformer/parity-fixture/v1", + "name": "html-asymmetric-grid-css-owned-grid-carrier", + "description": "An inline-styled grid with asymmetric tracks (260px 1fr) is not expressible as native WordPress grid layout. Instead of the flow demotion that silently drops the grid geometry, the container must carry a blocks-engine-css-owned-grid marker plus a generated stylesheet rule preserving display:grid, the track list, and the gap.", + "source_reference": { + "repo": "php-transformer", + "path": "tests/fixtures/parity/html-asymmetric-grid-css-owned-grid-carrier.json", + "notes": "Derived from portfolio sites whose sidebar/content splits use inline display:grid with asymmetric fr tracks; the css-owned flow demotion stacked the panes vertically because no mechanism carried the inline grid declarations into the generated stylesheet." + }, + "legacy_comparison": { + "skip": true, + "reason": "Covers current PHP transformer layout classification behavior; no downstream legacy comparison." + }, + "operation": "html_transformer.transform", + "input": { + "content": "

Selected stills from the field.

Field Notes

Photographs from three seasons of survey work.

" + }, + "expected_blocks": [ + { "path": "blocks.0", "name": "core/group" } + ], + "expected_fallbacks": [], + "expect": [ + { "path": "status", "assert": "equals", "value": "success" }, + { "path": "fallbacks", "assert": "count", "count": 0 }, + { "path": "blocks.0.innerBlocks", "assert": "count", "count": 2 }, + { "path": "serialized_blocks", "assert": "contains", "value": "blocks-engine-css-owned-grid" }, + { "path": "serialized_blocks", "assert": "not_contains", "value": "blocks-engine-css-owned-flow" }, + { "path": "assets.0.content", "assert": "contains", "value": "display:grid" }, + { "path": "assets.0.content", "assert": "contains", "value": "grid-template-columns:260px 1fr" }, + { "path": "assets.0.content", "assert": "contains", "value": "gap:32px" } + ] +} 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 new file mode 100644 index 00000000..99c8c6b8 --- /dev/null +++ b/php-transformer/tests/fixtures/parity/html-autofit-grid-carries-gap-and-background.json @@ -0,0 +1,35 @@ +{ + "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) must carry both onto the native grid group: the author gap becomes blockGap so WordPress does not substitute its default gap, and the container background becomes a color support so the dividers survive without the author stylesheet.", + "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; mapping the grid to native layout without carrying the gap rendered WordPress's default block gap instead of 1px dividers." + }, + "legacy_comparison": { + "skip": true, + "reason": "Covers current PHP transformer layout classification behavior; no downstream legacy comparison." + }, + "operation": "html_transformer.transform", + "input": { + "content": "

Ledger

Atlas

Relay

" + }, + "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.attrs.style.spacing.blockGap", "assert": "equals", "value": "1px" }, + { "path": "blocks.0.attrs.style.color.background", "assert": "equals", "value": "#1a1a1a" }, + { "path": "serialized_blocks", "assert": "contains", "value": "is-layout-grid" } + ] +} diff --git a/php-transformer/tests/fixtures/parity/html-autofit-grid-carries-zero-gap.json b/php-transformer/tests/fixtures/parity/html-autofit-grid-carries-zero-gap.json new file mode 100644 index 00000000..37c16247 --- /dev/null +++ b/php-transformer/tests/fixtures/parity/html-autofit-grid-carries-zero-gap.json @@ -0,0 +1,31 @@ +{ + "schema": "blocks-engine/php-transformer/parity-fixture/v1", + "name": "html-autofit-grid-carries-zero-gap", + "description": "An auto-fit grid with an explicit unitless gap:0 must carry blockGap '0' onto the native grid group; otherwise WordPress substitutes the theme's default block gap for a grid the author declared gapless.", + "source_reference": { + "repo": "php-transformer", + "path": "tests/fixtures/parity/html-autofit-grid-carries-zero-gap.json", + "notes": "Companion to html-autofit-grid-carries-gap-and-background: the unitless-zero form of the gap declaration failed the original number+unit pattern, so gapless card walls rendered with the theme default gap." + }, + "legacy_comparison": { + "skip": true, + "reason": "Covers current PHP transformer layout classification behavior; no downstream legacy comparison." + }, + "operation": "html_transformer.transform", + "input": { + "content": "

One

Two

Three

" + }, + "expected_blocks": [ + { + "path": "blocks.0", + "name": "core/group", + "attrs": { "layout": { "type": "grid", "minimumColumnWidth": "200px" } } + } + ], + "expected_fallbacks": [], + "expect": [ + { "path": "status", "assert": "equals", "value": "success" }, + { "path": "fallbacks", "assert": "count", "count": 0 }, + { "path": "blocks.0.attrs.style.spacing.blockGap", "assert": "equals", "value": "0" } + ] +} 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 new file mode 100644 index 00000000..a226be79 --- /dev/null +++ b/php-transformer/tests/fixtures/parity/html-autofit-grid-inline-leaf-items.json @@ -0,0 +1,36 @@ +{ + "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 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.", + "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." + }, + "legacy_comparison": { + "skip": true, + "reason": "Covers current PHP transformer layout classification behavior; no downstream legacy comparison." + }, + "operation": "html_transformer.transform", + "input": { + "content": "
Acme CorpNorthwindInitechUmbrella
" + }, + "expected_blocks": [ + { + "path": "blocks.0", + "name": "core/group", + "attrs": { "layout": { "type": "grid", "minimumColumnWidth": "190px" } } + }, + { "path": "blocks.0.innerBlocks.0", "name": "core/paragraph" }, + { "path": "blocks.0.innerBlocks.3", "name": "core/paragraph" } + ], + "expected_fallbacks": [], + "expect": [ + { "path": "status", "assert": "equals", "value": "success" }, + { "path": "fallbacks", "assert": "count", "count": 0 }, + { "path": "blocks.0.innerBlocks", "assert": "count", "count": 4 }, + { "path": "blocks.0.attrs.style.spacing.blockGap", "assert": "equals", "value": "24px" }, + { "path": "serialized_blocks", "assert": "contains", "value": "is-layout-grid" }, + { "path": "serialized_blocks", "assert": "contains", "value": "

Acme Corp

" } + ] +} 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 new file mode 100644 index 00000000..a04ba5a4 --- /dev/null +++ b/php-transformer/tests/fixtures/parity/html-autofit-grid-maps-to-minimum-column-width.json @@ -0,0 +1,34 @@ +{ + "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-context-syntax-card-grid.json b/php-transformer/tests/fixtures/parity/html-context-syntax-card-grid.json index 5f0d7627..ac72b30c 100644 --- a/php-transformer/tests/fixtures/parity/html-context-syntax-card-grid.json +++ b/php-transformer/tests/fixtures/parity/html-context-syntax-card-grid.json @@ -40,7 +40,7 @@ { "path": "fallbacks.1.repair_bucket", "assert": "equals", "value": "restore_interactive_behavior" }, { "path": "diagnostics.2.runtime_island_type", "assert": "equals", "value": "unsupported_custom_app_control" }, { "path": "serialized_blocks", "assert": "contains", "value": "const count = 2;" }, - { "path": "serialized_blocks", "assert": "contains", "value": "cards-grid blocks-engine-css-owned-layout" }, + { "path": "serialized_blocks", "assert": "contains", "value": "blocks-engine-css-owned-layout blocks-engine-css-owned-grid" }, { "path": "serialized_blocks", "assert": "contains", "value": "
" }, { "path": "serialized_blocks", "assert": "not_contains", "value": "blocks-engine/author-layout" }, { "path": "source_reports.html.source_provenance.0.context.structure_signals.grid_like", "assert": "equals", "value": true }, diff --git a/php-transformer/tests/fixtures/parity/html-css-grid-row-stays-transparent-group.json b/php-transformer/tests/fixtures/parity/html-css-grid-row-stays-transparent-group.json index 0a31df4c..73f3c0d5 100644 --- a/php-transformer/tests/fixtures/parity/html-css-grid-row-stays-transparent-group.json +++ b/php-transformer/tests/fixtures/parity/html-css-grid-row-stays-transparent-group.json @@ -24,7 +24,7 @@ "expected_fallbacks": [], "expect": [ { "path": "status", "assert": "equals", "value": "success" }, - { "path": "serialized_blocks", "assert": "contains", "value": "event-row blocks-engine-css-owned-layout blocks-engine-css-owned-flow" }, + { "path": "serialized_blocks", "assert": "contains", "value": "blocks-engine-css-owned-layout blocks-engine-css-owned-grid" }, { "path": "serialized_blocks", "assert": "contains", "value": "date blocks-engine-css-owned-layout" }, { "path": "serialized_blocks", "assert": "not_contains", "value": "blocks-engine/author-layout" }, { "path": "serialized_blocks", "assert": "not_contains", "value": "" }, - { "path": "serialized_blocks", "assert": "contains", "value": "
" }, + { "path": "serialized_blocks", "assert": "contains", "value": "" }, + { "path": "serialized_blocks", "assert": "contains", "value": "
" }, { "path": "serialized_blocks", "assert": "contains", "value": "\"className\":\"reveal reveal-delay-1\"" }, { "path": "serialized_blocks", "assert": "contains", "value": "" } ] diff --git a/php-transformer/tests/fixtures/parity/html-gallery-figure-and-div-recognizer.json b/php-transformer/tests/fixtures/parity/html-gallery-figure-and-div-recognizer.json index d11419cf..bd53d748 100644 --- a/php-transformer/tests/fixtures/parity/html-gallery-figure-and-div-recognizer.json +++ b/php-transformer/tests/fixtures/parity/html-gallery-figure-and-div-recognizer.json @@ -19,7 +19,7 @@ { "path": "blocks.0", "name": "core/gallery", "attrs": { "className": "photo-strip", "caption": "Strip caption" } }, { "path": "blocks.0.innerBlocks.0", "name": "core/image", "attrs": { "url": "https://example.com/a.jpg", "alt": "A" } }, { "path": "blocks.0.innerBlocks.1", "name": "core/image", "attrs": { "url": "https://example.com/b.jpg", "alt": "B" } }, - { "path": "blocks.1", "name": "core/gallery", "attrs": { "className": "media-grid", "layout": { "type": "grid" } } }, + { "path": "blocks.1", "name": "core/gallery", "attrs": { "className": "media-grid" } }, { "path": "blocks.1.innerBlocks.0", "name": "core/image", "attrs": { "url": "https://example.com/c.jpg", "alt": "C" } }, { "path": "blocks.1.innerBlocks.1", "name": "core/image", "attrs": { "className": "tile is-resized", "url": "https://example.com/d.jpg", "alt": "D", "width": "400", "height": "300", "caption": "D caption" } } ], @@ -28,7 +28,7 @@ { "path": "status", "assert": "equals", "value": "success" }, { "path": "blocks", "assert": "count", "count": 2 }, { "path": "serialized_blocks", "assert": "contains", "value": "" }, - { "path": "serialized_blocks", "assert": "contains", "value": "" }, + { "path": "serialized_blocks", "assert": "contains", "value": "" }, { "path": "serialized_blocks", "assert": "contains", "value": "" }, { "path": "fallbacks", "assert": "count", "count": 0 }, { "path": "coverage.0.fallback_count", "assert": "equals", "value": 0 } diff --git a/php-transformer/tests/fixtures/parity/html-head-meta-description-surfaced.json b/php-transformer/tests/fixtures/parity/html-head-meta-description-surfaced.json new file mode 100644 index 00000000..abbee2cc --- /dev/null +++ b/php-transformer/tests/fixtures/parity/html-head-meta-description-surfaced.json @@ -0,0 +1,28 @@ +{ + "schema": "blocks-engine/php-transformer/parity-fixture/v1", + "name": "html-head-meta-description-surfaced", + "description": "Named head metadata (meta name=description and social property tags) must surface in the transform result instead of being silently stripped, so consumers can consciously carry it to the destination document.", + "source_reference": { + "repo": "php-transformer", + "path": "tests/fixtures/parity/html-head-meta-description-surfaced.json", + "notes": "Derived from design-preview pipelines where meta name=description vanished between authored HTML and preview output with no fallback or diagnostic, reading as a malformed design rather than a deliberate omission." + }, + "legacy_comparison": { + "skip": true, + "reason": "Covers current PHP transformer head metadata reporting; no downstream legacy comparison." + }, + "operation": "html_transformer.transform", + "input": { + "content": "Mira Vale

Work

" + }, + "expected_blocks": [], + "expected_fallbacks": [], + "expect": [ + { "path": "status", "assert": "equals", "value": "success" }, + { "path": "fallbacks", "assert": "count", "count": 0 }, + { "path": "source_reports.head_metadata", "assert": "count", "count": 2 }, + { "path": "source_reports.head_metadata.0.name", "assert": "equals", "value": "description" }, + { "path": "source_reports.head_metadata.0.content", "assert": "equals", "value": "Portfolio of Mira Vale, independent creative director." }, + { "path": "source_reports.head_metadata.1.property", "assert": "equals", "value": "og:title" } + ] +} diff --git a/php-transformer/tests/fixtures/parity/html-inline-split-grid-stays-group.json b/php-transformer/tests/fixtures/parity/html-inline-split-grid-stays-group.json index 64ad8513..d8559282 100644 --- a/php-transformer/tests/fixtures/parity/html-inline-split-grid-stays-group.json +++ b/php-transformer/tests/fixtures/parity/html-inline-split-grid-stays-group.json @@ -23,7 +23,7 @@ "expected_fallbacks": [], "expect": [ { "path": "status", "assert": "equals", "value": "success" }, - { "path": "serialized_blocks", "assert": "contains", "value": "
" }, + { "path": "serialized_blocks", "assert": "contains", "value": "blocks-engine-css-owned-layout blocks-engine-css-owned-grid\">" }, { "path": "serialized_blocks", "assert": "contains", "value": "feature-copy blocks-engine-css-owned-layout" }, { "path": "serialized_blocks", "assert": "not_contains", "value": "blocks-engine/author-layout" }, { "path": "serialized_blocks", "assert": "not_contains", "value": "