diff --git a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php
new file mode 100644
index 00000000..788579b8
--- /dev/null
+++ b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php
@@ -0,0 +1,11551 @@
+
+ */
+ public static function emittedCoreBlockContracts(): array
+ {
+ return array(
+ 'core/accordion' => 'html_transformer_contract',
+ 'core/accordion-heading' => 'html_transformer_contract',
+ 'core/accordion-item' => 'html_transformer_contract',
+ 'core/accordion-panel' => 'html_transformer_contract',
+ 'core/audio' => 'html_transformer_contract',
+ 'core/button' => 'html_transformer_contract',
+ 'core/buttons' => 'html_transformer_contract',
+ 'core/code' => 'html_transformer_contract',
+ 'core/column' => 'html_transformer_contract',
+ 'core/columns' => 'html_transformer_contract',
+ 'core/cover' => 'html_transformer_contract',
+ 'core/details' => 'html_transformer_contract',
+ 'core/embed' => 'html_transformer_contract',
+ 'core/file' => 'html_transformer_contract',
+ 'core/gallery' => 'html_transformer_contract',
+ 'core/group' => 'html_transformer_contract',
+ 'core/heading' => 'html_transformer_contract',
+ 'core/image' => 'html_transformer_contract',
+ 'core/list' => 'html_transformer_contract',
+ 'core/list-item' => 'html_transformer_contract',
+ 'core/math' => 'html_transformer_contract',
+ 'core/media-text' => 'html_transformer_contract',
+ 'core/navigation' => 'html_transformer_contract',
+ 'core/navigation-link' => 'html_transformer_contract',
+ 'core/navigation-submenu' => 'html_transformer_contract',
+ 'core/paragraph' => 'html_transformer_contract',
+ 'core/preformatted' => 'html_transformer_contract',
+ 'core/pullquote' => 'html_transformer_contract',
+ 'core/quote' => 'html_transformer_contract',
+ 'core/search' => 'html_transformer_contract',
+ 'core/separator' => 'html_transformer_contract',
+ 'core/shortcode' => 'html_transformer_contract',
+ 'core/social-link' => 'html_transformer_contract',
+ 'core/social-links' => 'html_transformer_contract',
+ 'core/spacer' => 'html_transformer_contract',
+ 'core/table' => 'html_transformer_contract',
+ 'core/video' => 'html_transformer_contract',
+ );
+ }
+
+ /**
+ * Reference viewport width (px) for resolving responsive image constraints
+ * (aspect-ratio/object-fit) to the single value WordPress' core/image carries.
+ * min-width @media overrides at or below this width may win over the base rule.
+ */
+ private const DESKTOP_REFERENCE_WIDTH = 1440;
+
+ /**
+ * Root font size (px) used to resolve `em`/`rem` media-query breakpoints.
+ * Media features resolve these against the initial value, not any authored
+ * font-size, so the CSS default is the correct constant rather than a guess.
+ */
+ private const ROOT_FONT_SIZE_PX = 16;
+
+ /**
+ * Tag-only script selectors that must keep their native DOM shape when a
+ * first-party runtime binds directly to them.
+ *
+ * @var array
+ */
+ private const RUNTIME_TAG_SELECTORS = array( 'button', 'input', 'select', 'textarea', 'ul', 'ol', 'li', 'span', 'menu', 'menuitem' );
+
+ /**
+ * Generic class/id tokens that usually mark a JS-owned application surface
+ * rather than editorial content. Used only with runtime selector evidence.
+ *
+ * @var array
+ */
+ private const RUNTIME_APP_ROOT_TOKENS = array(
+ 'app', 'application', 'board', 'canvas', 'dashboard', 'desktop', 'editor',
+ 'explorer', 'instrument', 'lab', 'playground', 'rack', 'scene', 'shell',
+ 'simulator', 'stage', 'studio', 'terminal', 'viewport', 'workspace', 'world',
+ );
+
+ /**
+ * Blocks that manage their own link destination and must never receive a
+ * propagated card-link wrapper href (core/button owns its `url`,
+ * core/navigation-link owns its `url`, core/html is opaque markup, …).
+ *
+ * @var array
+ */
+ private const LINK_SELF_MANAGING_BLOCKS = array(
+ 'core/button',
+ 'core/buttons',
+ 'core/file',
+ 'core/html',
+ 'core/navigation',
+ 'core/navigation-link',
+ 'core/navigation-submenu',
+ );
+
+ /**
+ * RichText content blocks whose stored `content` can carry an inline ``
+ * when a whole-element link wrapper is propagated onto them (#260).
+ *
+ * @var array
+ */
+ private const LINK_BEARING_TEXT_BLOCKS = array(
+ 'core/heading',
+ 'core/paragraph',
+ 'core/list-item',
+ );
+
+ private readonly BlockFactory $blockFactory;
+
+ private readonly BlockMaterializer $blockMaterializer;
+
+ private readonly BackgroundImageExtractor $backgroundImageExtractor;
+
+ private readonly TableClassificationPolicy $tableClassificationPolicy;
+
+ private readonly PatternRecognizerRegistry $patternRecognizers;
+
+ private readonly TextLeafElementConverter $textLeafConverter;
+
+ private readonly RichTextElementConverter $richTextConverter;
+
+ private readonly StyleResolver $styleResolver;
+
+ private readonly GeneratedBlockStyleProjector $generatedBlockStyleProjector;
+
+ private readonly SourceBlockAttributeProjector $sourceBlockAttributeProjector;
+
+ private readonly StylesheetAnalysisComposer $stylesheetAnalysisComposer;
+
+ private readonly AuthorSelectorSemanticPreparer $authorSelectorSemanticPreparer;
+
+ private readonly AuthorStylesheetProjector $authorStylesheetProjector;
+
+ private readonly NavigationStyleProjector $navigationStyleProjector;
+
+ private readonly SvgMaterializer $svgMaterializer;
+
+ private readonly OrderedElementConverterRegistry $structuralContentConverters;
+
+ private readonly OrderedElementConverterRegistry $specializedElementConverters;
+
+ private readonly NavigationToggleSuppressor $navigationToggleSuppressor;
+
+ private readonly RuntimeIslandAnalyzer $runtimeIslands;
+
+ private readonly RuntimeResourceElementConverter $runtimeResourceConverter;
+
+ private readonly FormRuntimeIslandRecorder $formRuntimeIslandRecorder;
+
+ private readonly FormControlMetadataBuilder $formControlMetadataBuilder;
+
+ private readonly AuthoredFormControlBlockConverter $authoredFormControlBlockConverter;
+
+ private readonly ReadableFormControlBlockConverter $readableFormControlBlockConverter;
+
+ private readonly ReadableFormBlockBuilder $readableFormBlockBuilder;
+
+ private readonly FormCompositionPlanner $formCompositionPlanner;
+
+ private readonly FormFallbackFindingBuilder $formFallbackFindingBuilder;
+
+ private readonly FormDispatcher $formDispatcher;
+
+ private readonly PseudoFormAnalyzer $pseudoFormAnalyzer;
+
+ private readonly FormRuntimeRequirementAnalyzer $formRuntimeRequirementAnalyzer;
+
+ private readonly FormSuccessPanelMetadataBuilder $formSuccessPanelMetadataBuilder;
+
+ private readonly SearchBlockConverter $searchBlockConverter;
+
+ private readonly ButtonLinkDispatcher $buttonLinkDispatcher;
+
+ private readonly OrderedElementConverterRegistry $tableConverters;
+
+ private readonly FlowContainerElementConverter $flowContainerConverter;
+
+ private readonly MediaDispatchElementConverter $mediaDispatchConverter;
+
+ private readonly UnsupportedElementRecorder $unsupportedRecorder;
+
+ private readonly PatternContext $patternContext;
+
+ private readonly PatternContext $patternContextWithoutRuntimeDomTarget;
+
+ private readonly PatternContext $patternProbeContext;
+
+ private readonly NavigationUnderlineColorResolver $navigationUnderlineColorResolver;
+
+ private readonly SourceElementClassifier $sourceElementClassifier;
+ private readonly NavigationBlockNormalizer $navigationBlockNormalizer;
+
+ private readonly DiagnosticsCollector $diagnosticsCollector;
+
+ private readonly SemanticParityReporter $semanticParityReporter;
+
+ private readonly ContentRoundTripReporter $contentRoundTripReporter;
+
+ private readonly ReusableComponentRecognizer $reusableComponentRecognizer;
+
+ private HtmlTransformerSession $session;
+
+ private const SYNTHETIC_PARAGRAPH_CLASS = SourceBlockAttributeProjector::SYNTHETIC_PARAGRAPH_CLASS;
+
+ private const SYNTHETIC_ANCHOR_UNDECORATED_CLASS = SourceBlockAttributeProjector::SYNTHETIC_ANCHOR_UNDECORATED_CLASS;
+
+ private const SYNTHETIC_IMAGE_FIGURE_CLASS = SourceBlockAttributeProjector::SYNTHETIC_IMAGE_FIGURE_CLASS;
+
+ private const BACKGROUND_IMAGE_CLASS = 'blocks-engine-background-image';
+
+ private const BACKGROUND_IMAGE_SCALE_CLASS_PREFIX = 'blocks-engine-background-image-';
+
+ private const INLINE_LAYOUT_CARRIER_CLASS = AuthorStylesheetProjector::INLINE_LAYOUT_CARRIER_CLASS;
+
+
+ private const EMPTY_FLEX_ITEM_CLASS = 'blocks-engine-empty-flex-item';
+
+ public const EMPTY_VISUAL_GROUP_CLASS = 'blocks-engine-empty-visual-group';
+
+ /**
+ * Marks an emptied block that exists only as a runtime target. Emitted
+ * here and read back by {@see NavigationStyleProjector}, which projects the
+ * editor static-state CSS that hides it.
+ */
+ public const EMPTY_RUNTIME_TARGET_CLASS = 'blocks-engine-empty-runtime-target';
+
+ /**
+ * Marks a RichText block whose text was painted by the source, and into
+ * whose content a content-wrapping anchor's link had to be pushed. The
+ * synthesized inline `` becomes the text's nearest painted ancestor, so
+ * the browser's link colour would replace the source colour; the projected
+ * rule makes that anchor inherit its host block's colour instead.
+ */
+ private const PROPAGATED_LINK_COLOR_CARRIER_CLASS = 'blocks-engine-propagated-link-color';
+
+ private const CSS_OWNED_LAYOUT_CLASS = 'blocks-engine-css-owned-layout';
+
+ private const CSS_OWNED_FLOW_CLASS = 'blocks-engine-css-owned-flow';
+
+ private const CSS_OWNED_GRID_CLASS = 'blocks-engine-css-owned-grid';
+
+ private const CSS_OWNED_INLINE_FLOW_CLASS = SourceBlockAttributeProjector::CSS_OWNED_INLINE_FLOW_CLASS;
+
+ /** @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',
+ );
+
+ /** @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 = SourceBlockAttributeProjector::CSS_OWNED_LAYOUT_ITEM_CLASS;
+
+ public function __construct(
+ private readonly Runtime $runtime = new Runtime(),
+ private readonly HtmlTransformerAnalysisCache $analysisCache = new HtmlTransformerAnalysisCache()
+ )
+ {
+ $this->session = new HtmlTransformerSession(
+ $this->runtime,
+ fn (DOMElement $element): array => $this->sourceContext($element)
+ );
+ $this->blockFactory = new BlockFactory();
+ $this->sourceElementClassifier = new SourceElementClassifier();
+ $this->backgroundImageExtractor = new BackgroundImageExtractor();
+ $this->tableClassificationPolicy = new TableClassificationPolicy();
+ $this->patternRecognizers = PatternRecognizerRegistry::createDefault();
+ $this->navigationUnderlineColorResolver = new NavigationUnderlineColorResolver();
+ $this->navigationBlockNormalizer = new NavigationBlockNormalizer(fn (string $label): string => $this->normalizedNavigationLabel($label));
+ $this->diagnosticsCollector = new DiagnosticsCollector();
+ $this->semanticParityReporter = new SemanticParityReporter(
+ $this->runtime,
+ new TypographyParityAnalyzer(new FontMaterializationPlanBuilder($this->analysisCache->cssFontAnalysis))
+ );
+ $this->contentRoundTripReporter = new ContentRoundTripReporter();
+ $this->reusableComponentRecognizer = new ReusableComponentRecognizer();
+ $this->patternContext = $this->createPatternContext(true);
+ $this->patternContextWithoutRuntimeDomTarget = $this->createPatternContext(false);
+ $this->patternProbeContext = $this->createProbePatternContext();
+ $this->textLeafConverter = new TextLeafElementConverter($this->createTextLeafElementContext());
+ $this->richTextConverter = new RichTextElementConverter($this->createRichTextElementContext());
+ $this->styleResolver = new StyleResolver($this->createStyleResolutionContext(), $this->analysisCache);
+ $this->generatedBlockStyleProjector = new GeneratedBlockStyleProjector($this->runtime, $this->styleResolver);
+ $this->sourceBlockAttributeProjector = new SourceBlockAttributeProjector($this->styleResolver, $this->generatedBlockStyleProjector);
+ $this->stylesheetAnalysisComposer = new StylesheetAnalysisComposer($this->styleResolver, $this->analysisCache);
+ $this->authorSelectorSemanticPreparer = new AuthorSelectorSemanticPreparer(
+ new AuthorSelectorSemanticContext(
+ fn (DOMElement $element): bool => $this->isDirectChildOfAuthorOwnedLayout($element),
+ fn (string $tagName): bool => $this->sourceElementClassifier->isInlineContentElement($tagName),
+ fn (DOMElement $element): bool => $this->isStructuralListItem($element),
+ fn (DOMElement $element): bool => $this->requiresIndependentSemanticWrapper($element),
+ fn (DOMElement $element): bool => $this->requiresStandaloneInlineLayoutLeaf($element),
+ fn (DOMElement $table): bool => $this->isRepresentableTable($table)
+ ),
+ $this->stylesheetAnalysisComposer,
+ $this->styleResolver,
+ $this->analysisCache
+ );
+ $authorStyleRuleProjector = new AuthorStyleRuleProjector($this->styleResolver, $this->authorSelectorSemanticPreparer);
+ $this->authorStylesheetProjector = new AuthorStylesheetProjector(
+ $this->styleResolver,
+ $this->authorSelectorSemanticPreparer,
+ $authorStyleRuleProjector
+ );
+ $this->navigationStyleProjector = new NavigationStyleProjector(
+ $this->createNavigationStyleProjectionContext(),
+ $this->styleResolver
+ );
+ $this->navigationToggleSuppressor = new NavigationToggleSuppressor(
+ $this->createNavigationToggleSuppressionContext(),
+ $this->styleResolver
+ );
+ $this->svgMaterializer = new SvgMaterializer(
+ $this->createSvgMaterializationContext(),
+ $this->styleResolver,
+ $this->runtime
+ );
+ $svgConverter = new SvgElementConverter(new SvgElementContext(
+ fn (DOMElement $element): bool => $this->isInertHiddenSvgStorage($element),
+ fn (DOMElement $element): bool => $this->runtimeIslands->isRuntimeDomTarget($element),
+ fn (DOMElement $element): string => $this->sanitizeInlineSvgMarkup($element),
+ fn (string $html): bool => $this->isSafeSvgContent($html),
+ fn (DOMElement $element): bool => $this->sourceElementClassifier->isVisualLayerElement($element),
+ fn (DOMElement $element): bool => $this->svgHasDrawableContent($element),
+ fn (DOMElement $element): array => $this->styleResolver->presentationAttributes($element),
+ fn (string $name, array $attributes, array $innerBlocks, ?DOMElement $sourceElement): array => $this->createBlock($name, $attributes, $innerBlocks, $sourceElement),
+ function (DOMElement $element, array &$fallbacks): void {
+ $this->captureInlineSvgFallback($element, $fallbacks);
+ }
+ ), $this->svgMaterializer);
+ $inlineContentConverter = new InlineContentElementConverter(new InlineContentElementContext(
+ fn (DOMElement $element, array &$fallbacks, array $patterns): ?array => $this->recognizePatterns($element, $fallbacks, $patterns),
+ fn (DOMElement $element): bool => $this->runtimeIslands->isRuntimeDomTarget($element),
+ fn (DOMElement $element): array => $this->htmlPreservationBlock($element),
+ fn (DOMElement $element): ?array => $this->inlineSvgTextGroupBlockFromElement($element),
+ fn (DOMElement $element): bool => $this->ownsPositioningGeometry($element),
+ fn (DOMElement $element, array &$fallbacks): ?array => $this->positionedInlineCarrierBlock($element, $fallbacks),
+ fn (DOMElement $element): bool => $this->hasAuthorSemanticMarker($element),
+ fn (string $content): bool => $this->richTextContentHasStructuralHtml($content),
+ fn (DOMElement $element, array &$fallbacks, bool $captureUnsupported): array => $this->convertChildren($element, $fallbacks, $captureUnsupported),
+ fn (string $name, array $attributes, array $innerBlocks, ?DOMElement $sourceElement): array => $this->createBlock($name, $attributes, $innerBlocks, $sourceElement),
+ fn (DOMElement $element): string => $this->richTextMarkerForElement($element),
+ fn (DOMElement $element): bool => $this->sourceElementClassifier->hasBlockContentChildren($element),
+ fn (DOMElement $element): array => $this->richTextInlineVisualDeclarations($element),
+ fn (DOMElement $element): ?string => $this->dynamicTextContent($element),
+ fn (DOMElement $element, string $tagName): ?DOMElement => $this->ancestorElement($element, $tagName),
+ fn (DOMElement $element): bool => $this->isStructuralListItem($element),
+ fn (DOMElement $element): bool => $this->shouldPreserveEmptyVisualElement($element),
+ fn (DOMElement $element): array => $this->emptyVisualSpacerBlock($element)
+ ), $this->styleResolver, $this->runtime);
+ $this->formControlMetadataBuilder = new FormControlMetadataBuilder(
+ fn (DOMElement $element): string => $this->elementSelector($element),
+ fn (DOMElement $element): array => $this->styleResolver->presentationAttributes($element)
+ );
+ $this->authoredFormControlBlockConverter = new AuthoredFormControlBlockConverter(
+ $this->formControlMetadataBuilder,
+ fn (DOMElement $element): array => $this->styleResolver->structuralPresentationDeclarations($element),
+ fn (DOMElement $element): array => $this->styleResolver->presentationAttributes($element),
+ fn (string $name, array $attributes = array(), array $innerBlocks = array(), ?DOMElement $sourceElement = null): array => $this->createBlock($name, $attributes, $innerBlocks, $sourceElement),
+ function (string $identity, array $definition): void {
+ $this->generatedBlocks()->register($identity, $definition);
+ },
+ function (string $text): void {
+ $this->transformationEvidence()->recordFormControlEcho($text);
+ },
+ fn (string $text): string => $this->runtime->escapeHtml($text),
+ fn (string $id): string => $this->safeAnchor($id)
+ );
+ $this->pseudoFormAnalyzer = new PseudoFormAnalyzer($this->formControlMetadataBuilder, fn (DOMElement $element): string => $this->elementSelector($element));
+ $this->runtimeIslands = new RuntimeIslandAnalyzer($this->createRuntimeIslandContext(), $this->pseudoFormAnalyzer);
+ $this->blockMaterializer = new BlockMaterializer($this->blockFactory, $this->runtimeIslands);
+ $this->runtimeResourceConverter = new RuntimeResourceElementConverter(
+ fn (): HtmlTransformerSession => $this->session,
+ fn (DOMElement $element): array => $this->htmlPreservationBlock($element),
+ fn (string $name, array $attributes, array $innerBlocks, DOMElement $element): array => $this->createBlock($name, $attributes, $innerBlocks, $element)
+ );
+ $this->formRuntimeIslandRecorder = new FormRuntimeIslandRecorder(
+ $this->formControlMetadataBuilder,
+ function (DOMElement $element, string $kind, string $reason, string $capability, array $metadata): void {
+ $this->runtimeIslands->recordRuntimeIsland($element, $kind, $reason, $capability, $metadata);
+ },
+ fn (DOMElement $element): array => $this->eventMetadata($element),
+ fn (DOMElement $element): array => $this->requiredScriptsForElement($element)
+ );
+ $this->readableFormControlBlockConverter = new ReadableFormControlBlockConverter(
+ $this->formControlMetadataBuilder,
+ $this->authoredFormControlBlockConverter,
+ $this->formRuntimeIslandRecorder,
+ $this->runtime,
+ fn (DOMElement $element): array => $this->eventMetadata($element),
+ fn (DOMElement $element): bool => $this->runtimeIslands->isRuntimeDomTarget($element),
+ fn (DOMElement $element): array => $this->htmlPreservationBlock($element),
+ fn (DOMElement $element): array => $this->styleResolver->presentationAttributes($element),
+ fn (string $name, array $attributes = array(), array $innerBlocks = array(), ?DOMElement $sourceElement = null): array => $this->createBlock($name, $attributes, $innerBlocks, $sourceElement),
+ function (string $text): void {
+ $this->transformationEvidence()->recordFormControlEcho($text);
+ }
+ );
+ $this->readableFormBlockBuilder = new ReadableFormBlockBuilder(
+ $this->formControlMetadataBuilder,
+ $this->readableFormControlBlockConverter,
+ $this->formRuntimeIslandRecorder,
+ $this->runtime,
+ fn (DOMElement $element): array => $this->eventMetadata($element),
+ fn (DOMElement $element): bool => $this->runtimeIslands->isRuntimeDomTarget($element),
+ fn (DOMElement $element): array => $this->styleResolver->presentationAttributes($element),
+ fn (string $name, array $attributes = array(), array $innerBlocks = array(), ?DOMElement $sourceElement = null): array => $this->createBlock($name, $attributes, $innerBlocks, $sourceElement)
+ );
+ $this->formCompositionPlanner = new FormCompositionPlanner(
+ fn (): TransformationProvenanceState => $this->transformationProvenance(),
+ function (DOMElement $element, array &$fallbacks, bool $captureUnsupported): array {
+ return $this->convertChildren($element, $fallbacks, $captureUnsupported);
+ },
+ fn (DOMElement $element): array => $this->styleResolver->presentationAttributes($element),
+ fn (string $name, array $attributes = array(), array $innerBlocks = array(), ?DOMElement $sourceElement = null): array => $this->createBlock($name, $attributes, $innerBlocks, $sourceElement),
+ fn (DOMElement $container, DOMElement $element): bool => $this->elementContains($container, $element)
+ );
+ $this->formRuntimeRequirementAnalyzer = new FormRuntimeRequirementAnalyzer(
+ fn (DOMElement $element): array => $this->eventMetadata($element),
+ fn (DOMElement $element): bool => $this->runtimeIslands->isRuntimeDomTarget($element)
+ );
+ $this->formSuccessPanelMetadataBuilder = new FormSuccessPanelMetadataBuilder(
+ fn (DOMElement $element): string => $this->elementSelector($element),
+ fn (DOMElement $element): array => $this->boundedFallbackHtml($this->safeFallbackHtml($element)),
+ fn (DOMElement $element): string => $this->innerHtml($element)
+ );
+ $this->formFallbackFindingBuilder = new FormFallbackFindingBuilder(
+ new FormFallbackFindingContext(
+ fn (): array => $this->authorStyles()->stylesheetAssets(),
+ fn (): string => $this->sourceStyles()->formLayoutCss(),
+ fn (DOMElement $element): array => $this->boundedFallbackHtml($this->safeFallbackHtml($element)),
+ fn (DOMElement $element): array => $this->runtimeIslands->runtimeDomSelectorsForElement($element),
+ fn (DOMElement $element): array => $this->sourceContext($element),
+ fn (DOMElement $element): array => $this->fallbackEmitter()->classifyFallbackSubtree($element),
+ fn (array $block, string $role, array $supersededRuntimeSelectors): array => $this->blockBinding($block, $role, $supersededRuntimeSelectors),
+ fn (array $finding): array => FallbackDiagnostic::build($finding, $this->transformationProvenance()->fallback())
+ ),
+ $this->formControlMetadataBuilder,
+ $this->formSuccessPanelMetadataBuilder,
+ $this->pseudoFormAnalyzer
+ );
+ $this->searchBlockConverter = new SearchBlockConverter($this->createSearchBlockConversionContext(), $this->formControlMetadataBuilder, $this->pseudoFormAnalyzer);
+ $this->formDispatcher = new FormDispatcher(new FormDispatchContext(
+ fn (DOMElement $element): ?array => $this->searchBlockConverter->searchBlockFromForm($element),
+ function (DOMElement $element, array &$fallbacks): ?array {
+ return $this->formCompositionPlanner->compose($element, $fallbacks);
+ },
+ fn (DOMElement $element, ?array $readableFormBlock, ?array $bindingBlock = null): array => $this->formFallbackFindingBuilder->build($element, $readableFormBlock, $bindingBlock),
+ function (DOMElement $element, ?array $readableFormBlock): void {
+ $this->formRuntimeIslandRecorder->recordForm($element, $readableFormBlock);
+ },
+ fn (DOMElement $element, bool $allowFormEvents = false): ?array => $this->readableFormBlockBuilder->build($element, $allowFormEvents),
+ fn (DOMElement $element): bool => $this->formRuntimeRequirementAnalyzer->requiresPreservation($element),
+ fn (DOMElement $element): array => $this->htmlPreservationBlock($element),
+ fn (DOMElement $element): bool => $this->pseudoFormAnalyzer->isPseudoForm($element)
+ ));
+ $this->buttonLinkDispatcher = new ButtonLinkDispatcher($this->createButtonLinkDispatchContext());
+ $buttonConverter = new ButtonElementConverter(new ButtonElementContext(
+ fn (DOMElement $element): bool => $this->searchBlockConverter->isReplacedSearchClusterControl($element),
+ fn (DOMElement $element): bool => $this->sourceElementClassifier->isImageCarrierButton($element),
+ function (DOMElement $element, array &$fallbacks, bool $captureUnsupported): array {
+ return $this->convertChildren($element, $fallbacks, $captureUnsupported);
+ },
+ fn (DOMElement $element): array => $this->styleResolver->presentationAttributes($element),
+ fn (string $name, array $attributes, array $innerBlocks, DOMElement $element): array => $this->createBlock($name, $attributes, $innerBlocks, $element),
+ fn (DOMElement $element): ?array => $this->buttonLinkDispatcher->convertButton($element)
+ ));
+ $detailsConverter = new DetailsElementConverter(new DetailsElementContext(
+ fn (DOMElement $element): ?DOMElement => $this->capturedDisclosureDialog($element),
+ function (DOMElement $element, array &$fallbacks): array {
+ return $this->capturedDialogBlock($element, $fallbacks);
+ },
+ function (DOMElement $element, array &$fallbacks, array $patterns): ?array {
+ return $this->recognizePatterns($element, $fallbacks, $patterns);
+ }
+ ));
+ $this->specializedElementConverters = new OrderedElementConverterRegistry(array(
+ $detailsConverter,
+ $buttonConverter,
+ $svgConverter,
+ ));
+ $tableConverter = new TableElementConverter($this->createTableElementContext());
+ $parameterTableConverter = new PatternElementConverter(
+ new PatternElementContext(
+ function (DOMElement $element, array &$fallbacks, array $patterns): ?array {
+ return $this->recognizePatterns($element, $fallbacks, $patterns);
+ }
+ ),
+ array( ParameterTablePattern::class )
+ );
+ $this->tableConverters = new OrderedElementConverterRegistry(array(
+ $tableConverter,
+ $parameterTableConverter,
+ ));
+ $figureConverter = new FigureElementConverter(new FigureElementContext(
+ function (DOMElement $element, array &$fallbacks): ?array {
+ return $this->mediaGalleryBlockFromElement($element, $fallbacks);
+ },
+ function (DOMElement $element, array &$fallbacks, array $patterns): ?array {
+ return $this->recognizePatterns($element, $fallbacks, $patterns);
+ },
+ fn (DOMElement $figure): ?DOMElement => $this->figureLinkedMediaAnchor($figure),
+ fn (DOMElement $picture, ?DOMElement $figure = null, ?DOMElement $link = null): ?array => $this->convertPictureElement($picture, $figure, $link),
+ fn (DOMElement $image, ?DOMElement $figure = null, ?DOMElement $picture = null, ?DOMElement $link = null): ?array => $this->convertImageElement($image, $figure, $picture, $link),
+ fn (DOMElement $figure, string $tagName): ?DOMElement => $this->figureMediaElement($figure, $tagName),
+ function (DOMElement $figure, array &$fallbacks): ?array {
+ return $this->convertFigureGeneric($figure, $fallbacks);
+ },
+ fn (string $html): bool => '' !== trim($this->runtime->stripAllTags($html)),
+ fn (DOMElement $element): array => $this->styleResolver->presentationAttributes($element),
+ fn (string $name, array $attributes = array(), array $innerBlocks = array(), ?DOMElement $sourceElement = null): array => $this->createBlock($name, $attributes, $innerBlocks, $sourceElement)
+ ));
+ $quoteConverter = new QuoteElementConverter(new QuoteElementContext(
+ function (DOMElement $element, array &$fallbacks): ?array {
+ return $this->recognizePatterns($element, $fallbacks, array( QuotePattern::class ));
+ }
+ ));
+ $listConverter = new ListElementConverter(new ListElementContext(
+ function (DOMElement $element, array &$fallbacks, array $patterns): ?array {
+ return $this->recognizePatterns($element, $fallbacks, $patterns);
+ },
+ fn (array $block, DOMElement $element): array => $this->rememberAccordionDisclosureRoot($block, $element),
+ fn (DOMElement $element): bool => $this->isStructuredCardList($element),
+ function (DOMElement $element, array &$fallbacks): ?array {
+ return $this->decomposeStructuredCardList($element, $fallbacks);
+ },
+ fn (DOMElement $element): bool => $this->listContainsStructuralItemContent($element),
+ function (DOMElement $element, array &$fallbacks): ?array {
+ return $this->decomposeStructuralList($element, $fallbacks);
+ },
+ function (DOMElement $element, array &$fallbacks): array {
+ return $this->listItems($element, $fallbacks);
+ },
+ fn (DOMElement $element): bool => $this->isCssOwnedGridElement($element),
+ fn (DOMElement $element): array => $this->cssOwnedGridAttributes($element),
+ fn (DOMElement $element): array => $this->styleResolver->presentationAttributes($element),
+ fn (string $name, array $attributes, array $innerBlocks, ?DOMElement $sourceElement): array => $this->createBlock($name, $attributes, $innerBlocks, $sourceElement)
+ ));
+ $descriptionListConverter = new DescriptionListElementConverter(new DescriptionListElementContext(
+ fn (DOMElement $element): ?array => $this->descriptionListBlockFromElement($element),
+ fn (DOMElement $element): ?array => $this->metadataGridBlockFromElement($element),
+ fn (DOMElement $element): array => $this->definitionListItems($element),
+ fn (DOMElement $element): bool => $this->isCssOwnedGridElement($element),
+ fn (DOMElement $element): array => $this->cssOwnedGridAttributes($element),
+ fn (DOMElement $element): array => $this->styleResolver->presentationAttributes($element),
+ function (DOMElement $element, array &$fallbacks, bool $captureUnsupported): array {
+ return $this->convertChildren($element, $fallbacks, $captureUnsupported);
+ },
+ fn (string $name, array $attributes, array $innerBlocks, ?DOMElement $sourceElement): array => $this->createBlock($name, $attributes, $innerBlocks, $sourceElement),
+ fn (DOMElement $element): string => $this->richTextContentWithMaterializedInlineStyles($element),
+ fn (string $html): bool => '' !== trim($this->runtime->stripAllTags($html)),
+ fn (DOMElement $element): bool => $this->sourceElementClassifier->hasBlockContentChildren($element)
+ ));
+ $this->structuralContentConverters = new OrderedElementConverterRegistry(array(
+ $inlineContentConverter,
+ $listConverter,
+ $descriptionListConverter,
+ $quoteConverter,
+ $figureConverter,
+ ));
+ $this->flowContainerConverter = new FlowContainerElementConverter(new FlowContainerElementContext(
+ runtimeAppShellBlock: function (DOMElement $element, array &$fallbacks): ?array {
+ if ( ! $this->runtimeIslands->shouldPreserveRuntimeAppShell($element) ) {
+ return null;
+ }
+ $targets = $this->runtimeIslands->runtimeTargetsInSubtree($element, 8);
+ $this->runtimeIslands->recordRuntimeIsland($element, 'app_shell', 'runtime_app_shell', 'client_script_execution', array(
+ 'events' => $this->eventMetadata($element),
+ 'target_count' => count($targets),
+ 'targets' => $targets,
+ 'app_shell_signals' => $this->runtimeIslands->runtimeAppShellSignals($element),
+ 'required_scripts' => $this->requiredScriptsForElement($element),
+ ));
+ return $this->htmlPreservationBlock($element);
+ },
+ isEmptyInteractiveFeatureShell: fn (DOMElement $element): bool => $this->isEmptyInteractiveFeatureShell($element),
+ capturePseudoFormFallback: function (DOMElement $element, array &$fallbacks): void { $this->formDispatcher->capturePseudoFormFallback($element, $fallbacks); },
+ recognizePatterns: fn (DOMElement $element, array &$fallbacks, array $patterns): ?array => $this->recognizePatterns($element, $fallbacks, $patterns),
+ flankedSeparatorBlock: fn (DOMElement $element): ?array => $this->flankedSeparatorBlockFromElement($element),
+ capturedMediaLayoutBlock: fn (DOMElement $element): ?array => $this->capturedMediaLayoutBoundaryBlock($element),
+ sourceElementClassifier: $this->sourceElementClassifier,
+ responsiveMediaBlock: fn (DOMElement $element): array => $this->responsiveMediaBlock($element),
+ isDirectChildOfAuthorOwnedLayout: fn (DOMElement $element): bool => $this->isDirectChildOfAuthorOwnedLayout($element),
+ authorLayoutBlock: fn (DOMElement $element, array &$fallbacks): array => $this->authorLayoutBlockFromElement($element, $fallbacks),
+ hasMultipleRuntimeInlineTextTargets: fn (DOMElement $element): bool => $this->hasMultipleRuntimeInlineTextTargets($element),
+ paragraphBlockFromInlineContentWrapper: fn (DOMElement $element): ?array => $this->paragraphBlockFromInlineContentWrapper($element),
+ isGeneratedComponentCandidate: fn (DOMElement $element): bool => $this->isGeneratedComponentCandidate($element),
+ isAuthorOwnedLayout: fn (DOMElement $element): bool => $this->isAuthorOwnedLayout($element),
+ proofBackedWrapperCoalescing: fn (DOMElement $element, array &$fallbacks): ?array => $this->proofBackedWrapperCoalescing($element, $fallbacks),
+ shouldPreserveEmptyVisualElement: fn (DOMElement $element): bool => $this->shouldPreserveEmptyVisualElement($element),
+ emptyVisualElementAttributes: fn (DOMElement $element): array => $this->emptyVisualElementAttributes($element),
+ createBlock: fn (string $name, array $attributes, array $innerBlocks, ?DOMElement $sourceElement): array => $this->createBlock($name, $attributes, $innerBlocks, $sourceElement),
+ patternContext: $this->patternContext,
+ shouldDeferNavigationPatternToChildren: fn (DOMElement $element): bool => $this->shouldDeferNavigationPatternToChildren($element),
+ rememberAccordionDisclosureRoot: fn (array $block, DOMElement $element): array => $this->rememberAccordionDisclosureRoot($block, $element),
+ metadataGridBlock: fn (DOMElement $element): ?array => $this->metadataGridBlockFromElement($element),
+ rememberNativeDisclosureRoot: function (DOMElement $element): void { $this->runtimeBehavior()->rememberNativeDisclosureRoot($element->getNodePath() ?? ''); },
+ mediaGalleryBlock: fn (DOMElement $element, array &$fallbacks): ?array => $this->mediaGalleryBlockFromElement($element, $fallbacks),
+ namePriceRowBlock: fn (DOMElement $element, array &$fallbacks): ?array => $this->namePriceRowBlockFromElement($element, $fallbacks),
+ inlineTokenGroupBlock: fn (DOMElement $element, array &$fallbacks): ?array => $this->inlineTokenGroupBlockFromElement($element, $fallbacks),
+ visualTextWrapperBlock: fn (DOMElement $element): ?array => $this->visualTextWrapperBlockFromElement($element),
+ standaloneSearchBlock: fn (DOMElement $element): ?array => $this->searchBlockConverter->searchBlockFromStandaloneControl($element),
+ readableFormControlBlock: fn (DOMElement $element): ?array => $this->readableFormControlBlockConverter->convert($element),
+ authoredCarouselBlock: fn (DOMElement $element): ?array => $this->authoredCarouselBlock($element),
+ generatedComponentBlock: function (DOMElement $element): ?array {
+ $generated = $this->fallbackEmitter()->maybeGenerateCustomBlock($element, $this->generatedBlocks(), true, true);
+ return null !== $generated ? $this->generatedComponentBlock($generated, $element) : null;
+ },
+ textFlowBlock: fn (DOMElement $element): ?array => $this->textFlowBlockFromElement($element),
+ convertChildren: fn (DOMElement $element, array &$fallbacks): array => $this->convertChildren($element, $fallbacks, true),
+ backgroundImageBlock: fn (DOMElement $element): ?array => $this->backgroundImageBlockFromElement($element),
+ coalescedSingleGroupWrapper: fn (DOMElement $element, array $child): ?array => $this->coalescedSingleGroupWrapper($element, $child),
+ shouldPreserveWrapper: fn (DOMElement $element): bool => $this->shouldPreserveWrapper($element),
+ presentationAttributes: fn (DOMElement $element): array => $this->styleResolver->presentationAttributes($element),
+ emptyVisualSpacerBlock: fn (DOMElement $element): array => $this->emptyVisualSpacerBlock($element)
+ ));
+ $this->mediaDispatchConverter = new MediaDispatchElementConverter(new MediaDispatchElementContext(
+ function (DOMElement $element, array &$fallbacks): ?array {
+ return $this->recognizePatterns($element, $fallbacks, array( PlaceholderMediaPattern::class ));
+ },
+ fn (DOMElement $element): ?array => $this->convertImageElement($element),
+ fn (DOMElement $element): ?array => $this->convertPictureElement($element),
+ fn (DOMElement $element, array &$fallbacks): ?array => $this->convertIframeElement($element, $fallbacks),
+ fn (DOMElement $element): ?array => $this->convertMediaElement($element),
+ fn (DOMElement $element): ?array => $this->imageBlockFromAnchor($element)
+ ));
+ $this->unsupportedRecorder = new UnsupportedElementRecorder($this->createUnsupportedElementContext(), $this->formControlMetadataBuilder);
+ }
+
+
+ /** Source markup changed after selector analysis, so cached inputs are stale. */
+ private function onSourceMarkupMutated(): void
+ {
+ $this->sourceStyles()->invalidateSelectorMatches();
+ }
+
+ /**
+ * Collaborator surface for {@see StyleResolver}. Per-transform state is
+ * resolved lazily so the resolver always sees the running transform.
+ */
+ private function createStyleResolutionContext(): StyleResolutionContext
+ {
+ return new StyleResolutionContext(
+ fn (): AuthorStyleAnalysis => $this->authorStyles(),
+ fn (): SourceStyleResolutionState => $this->sourceStyles(),
+ fn (): LayoutGeometryState => $this->layoutGeometry(),
+ fn (): PresentationResolutionCache => $this->presentationResolutionCache(),
+ fn (): TransformationEvidenceState => $this->transformationEvidence(),
+ fn (DOMElement $element): int => $this->cardLikeChildCount($element),
+ fn (string $value): string => $this->cssComparableValue($value),
+ fn (string $selector): array => $this->parsedCssSelector($selector),
+ fn (string $className): string => $this->promotedClassName($className),
+ fn (string $url): string => $this->resolvedAssetImageUrl($url),
+ fn (DOMElement $element): bool => $this->authorSelectorProjections()->isRuntimeAttributePath($this->sourceElementIdentity($element))
+ );
+ }
+
+ /**
+ * Materializes a stylesheet into the transform's asset set.
+ *
+ * Transformer-owned rather than a navigation concern: engine-support and
+ * author stylesheets are materialized through here too. The navigation
+ * projector reaches it through {@see NavigationStyleProjectionContext}.
+ *
+ * @param array $cssParts
+ */
+ private function materializeStylesheetAsset(array $cssParts, string $source, string $placement, string $pathPrefix, string $target = 'both'): void
+ {
+ $css = trim(implode("\n\n", $cssParts));
+ if ( '' === $css ) {
+ return;
+ }
+
+ $content = $css . "\n";
+ $hash = hash('sha256', $content);
+ $path = 'assets/css/' . $pathPrefix . '-' . substr($hash, 0, 16) . '.css';
+
+ $this->materializedAssets()->register($path, array(
+ 'source' => $source,
+ 'source_path' => '',
+ 'path' => $path,
+ 'target_path' => $path,
+ 'kind' => 'css',
+ 'role' => 'stylesheet',
+ 'stylesheet_placement' => $placement,
+ 'stylesheet_target' => $target,
+ 'mime_type' => 'text/css',
+ 'media_type' => 'text/css',
+ 'content' => $content,
+ 'bytes' => strlen($content),
+ 'encoding' => 'utf-8',
+ 'binary' => false,
+ 'hash' => $hash,
+ 'source_hash' => $hash,
+ ));
+ }
+
+ /**
+ * Collaborator surface for {@see NavigationToggleSuppressor}. Per-transform
+ * state is resolved lazily so the suppressor always sees the running
+ * transform.
+ */
+ private function createNavigationToggleSuppressionContext(): NavigationToggleSuppressionContext
+ {
+ return new NavigationToggleSuppressionContext(
+ fn (DOMElement $element): bool => $this->sourceElementStartsHidden($element),
+ fn (): RuntimeSelectorState => $this->runtimeSelectors(),
+ fn (): NavigationProjectionState => $this->navigationProjection(),
+ fn (): PatternRecognizerRegistry => $this->patternRecognizers,
+ fn (): PatternContext => $this->probePatternContext()
+ );
+ }
+
+ /**
+ * Collaborator surface for {@see SvgMaterializer}. Per-transform state is
+ * resolved lazily so the materializer always sees the running transform.
+ */
+ private function createSvgMaterializationContext(): SvgMaterializationContext
+ {
+ return new SvgMaterializationContext(
+ fn (string $name, array $attrs = array(), array $innerBlocks = array(), ?DOMElement $sourceElement = null, ?DOMElement $logicalSourceElement = null): array
+ => $this->createBlock($name, $attrs, $innerBlocks, $sourceElement, $logicalSourceElement),
+ fn (string $tagName): bool => $this->sourceElementClassifier->isInlineContentElement($tagName),
+ fn (DOMElement $element): bool => $this->sourceElementClassifier->isVisualLayerElement($element),
+ fn (): LayoutGeometryState => $this->layoutGeometry(),
+ fn (): AssetMaterializationState => $this->materializedAssets(),
+ fn (DOMElement $element): ?string => $this->reusableComponentFingerprintFor($element),
+ fn (DOMElement $element): string => $this->safeFallbackHtml($element),
+ fn (DOMElement $element): string => $this->sanitizeInlineSvgMarkup($element),
+ fn (): TransformationEvidenceState => $this->transformationEvidence(),
+ fn (): TransformationProvenanceState => $this->transformationProvenance()
+ );
+ }
+
+ /** Collaborator surface for {@see SearchBlockConverter}. */
+ private function createSearchBlockConversionContext(): SearchBlockConversionContext
+ {
+ return new SearchBlockConversionContext(
+ fn (DOMElement $element): array => $this->styleResolver->presentationAttributes($element),
+ fn (DOMElement $element): array => $this->styleResolver->presentationDeclarations($element),
+ fn (string $name, array $attributes, array $innerBlocks, ?DOMElement $sourceElement): array => $this->createBlock($name, $attributes, $innerBlocks, $sourceElement),
+ fn (string $html): string => $this->svgMaterializer->restoreSvgCasing($html),
+ fn (): GeneratedSupportStylesheetState => $this->generatedSupportStyles(),
+ fn (DOMElement $element): bool => $this->runtimeIslands->isRuntimeDomTarget($element),
+ fn (DOMElement $element): array => $this->htmlPreservationBlock($element)
+ );
+ }
+
+ /**
+ * Collaborator surface for {@see NavigationStyleProjector}. Per-transform
+ * state is resolved lazily so the projector always sees the running
+ * transform.
+ */
+ private function createNavigationStyleProjectionContext(): NavigationStyleProjectionContext
+ {
+ return new NavigationStyleProjectionContext(
+ fn (): AuthorStyleAnalysis => $this->authorStyles(),
+ fn (): SourceStyleResolutionState => $this->sourceStyles(),
+ fn (): AuthorSelectorProjectionState => $this->session->authorSelectorProjectionState(),
+ fn (): GeneratedSupportStylesheetState => $this->generatedSupportStyles(),
+ fn (): RuntimeBehaviorState => $this->runtimeBehavior(),
+ fn (): TransformationEvidenceState => $this->transformationEvidence(),
+ fn (string $selector): array => $this->parsedCssSelector($selector),
+ function (array $cssParts, string $source, string $placement, string $pathPrefix, string $target = 'both'): void {
+ $this->materializeStylesheetAsset($cssParts, $source, $placement, $pathPrefix, $target);
+ }
+ );
+ }
+
+ /**
+ * Collaborator surface for {@see ButtonLinkDispatcher}.
+ */
+ private function createButtonLinkDispatchContext(): ButtonLinkDispatchContext
+ {
+ return new ButtonLinkDispatchContext(
+ fn (DOMElement $element): bool => $this->runtimeIslands->isRuntimeDomTarget($element),
+ function (DOMElement $element): void {
+ $this->formRuntimeIslandRecorder->recordControl($element);
+ },
+ fn (DOMElement $element): array => $this->htmlPreservationBlock($element),
+ function (DOMElement $element, array &$fallbacks, array $patterns): ?array {
+ return $this->recognizePatterns($element, $fallbacks, $patterns);
+ },
+ function (DOMElement $element, array &$fallbacks): ?array {
+ return $this->linkedSvgLogoBlockFromAnchor($element, $fallbacks);
+ },
+ fn (DOMElement $element): ?array => $this->imageBlockFromAnchor($element),
+ function (DOMElement $element, array &$fallbacks): ?array {
+ return $this->convertLinkWrapperGroup($element, $fallbacks);
+ },
+ fn (DOMElement $element, array $excludedProperties, array $excludedGeometryProperties): array => $this->styleResolver->presentationAttributes($element, $excludedProperties, $excludedGeometryProperties),
+ fn (string $name, array $attributes, array $innerBlocks, ?DOMElement $sourceElement): array => $this->createBlock($name, $attributes, $innerBlocks, $sourceElement),
+ fn (string $href): string => $this->safeLinkUrl($href),
+ fn (DOMElement $element): bool => $this->sourceElementClassifier->hasBlockContentChildren($element),
+ fn (DOMElement $element): array => $this->styleResolver->structuralPresentationDeclarations($element)
+ );
+ }
+
+ /**
+ * Collaborator surface for {@see RuntimeIslandAnalyzer}. Session-scoped
+ * state is resolved lazily so the analyzer always sees the running transform.
+ */
+ private function createRuntimeIslandContext(): RuntimeIslandContext
+ {
+ return new RuntimeIslandContext(
+ fn (): FallbackEmitter => $this->fallbackEmitter(),
+ fn (): RuntimeDomState => $this->runtimeDom(),
+ fn (): RuntimeSelectorState => $this->runtimeSelectors(),
+ fn (DOMElement $element): iterable => $this->descendantElements($element),
+ fn (DOMElement $element): array => $this->requiredScriptsForElement($element),
+ fn (string $html): ?DOMElement => $this->preservedHtmlRootElement($html),
+ fn (DOMElement $element): bool => $this->hasWorkspaceSurface($element),
+ fn (string $tagName): bool => $this->sourceElementClassifier->isInlineContentElement($tagName),
+ fn (string $selector): bool => $this->sourceElementClassifier->isPresentationalAnimationSelector($selector)
+ );
+ }
+
+ /**
+ * Collaborator surface for {@see TableElementConverter}.
+ */
+ private function createTableElementContext(): TableElementContext
+ {
+ return new TableElementContext(
+ $this->tableClassificationPolicy,
+ function (DOMElement $element, array &$fallbacks): ?array {
+ return $this->nestedLayoutTableColumnsBlock($element, $fallbacks);
+ },
+ function (DOMElement $element, array &$fallbacks): ?array {
+ return $this->mediaLayoutTableColumnsBlock($element, $fallbacks);
+ },
+ fn (DOMElement $element): array => $this->htmlPreservationBlock($element),
+ fn (DOMElement $element, array $excludedProperties, array $excludedGeometryProperties): array => $this->styleResolver->presentationAttributes($element, $excludedProperties, $excludedGeometryProperties),
+ fn (DOMElement $element): array => $this->tableAttributes($element),
+ fn (string $name, array $attributes, array $innerBlocks, ?DOMElement $sourceElement): array => $this->createBlock($name, $attributes, $innerBlocks, $sourceElement)
+ );
+ }
+
+ /**
+ * Collaborator surface for {@see UnsupportedElementRecorder}.
+ */
+ private function createUnsupportedElementContext(): UnsupportedElementContext
+ {
+ return new UnsupportedElementContext(
+ fn (DOMElement $element): ?array => $this->fallbackEmitter()->maybeGenerateCustomBlock($element, $this->generatedBlocks()),
+ fn (array $generated, DOMElement $element): array => $this->generatedComponentBlock($generated, $element),
+ fn (DOMElement $element): array => $this->sourceContext($element),
+ fn (DOMElement $element): array => $this->fallbackEmitter()->classifyFallbackSubtree($element),
+ fn (DOMElement $element): string => $this->safeFallbackHtml($element),
+ fn (array $fallback): array => FallbackDiagnostic::build($fallback, $this->transformationProvenance()->fallback())
+ );
+ }
+
+ /**
+ * Collaborator surface for {@see RichTextElementConverter}.
+ */
+ private function createRichTextElementContext(): RichTextElementContext
+ {
+ return new RichTextElementContext(
+ fn (DOMElement $element, array $excludedProperties, array $excludedGeometryProperties): array => $this->styleResolver->presentationAttributes($element, $excludedProperties, $excludedGeometryProperties),
+ fn (string $name, array $attributes, array $innerBlocks, ?DOMElement $sourceElement): array => $this->createBlock($name, $attributes, $innerBlocks, $sourceElement),
+ fn (DOMElement $element, array $excludedTags): string => $this->richTextContentWithMaterializedInlineStyles($element, $excludedTags),
+ fn (string $content): string => $this->headingRichTextContent($content),
+ fn (DOMElement $element, string $content): ?string => $this->richTextContentWithMaterializedSvgImages($element, $content),
+ fn (string $content): bool => $this->richTextRequiresHtmlFallbackWithoutNativeSvgImageObjects($content),
+ fn (string $content): bool => $this->richTextContainsNativeSvgImageObject($content),
+ fn (DOMElement $element): array => $this->htmlPreservationBlock($element),
+ fn (DOMElement $element): ?array => $this->authoredMarqueeBlock($element),
+ fn (DOMElement $element): bool => $this->hasEmptyVisualInlineChild($element),
+ fn (DOMElement $element): bool => $this->hasBoxChromeWrapperStyling($element),
+ fn (DOMElement $element): bool => $this->runtimeIslands->isRuntimeDomTarget($element),
+ fn (string $text): array => $this->convertText($text),
+ fn (string $html): string => $this->runtime->stripAllTags($html),
+ function (DOMElement $element, array &$fallbacks, bool $captureUnsupported): array {
+ return $this->convertChildren($element, $fallbacks, $captureUnsupported);
+ }
+ );
+ }
+
+ /**
+ * Collaborator surface for {@see TextLeafElementConverter}. Enumerating the
+ * operations here is the point: the converter cannot reach transformer
+ * state that is not listed.
+ */
+ private function createTextLeafElementContext(): TextLeafElementContext
+ {
+ return new TextLeafElementContext(
+ fn (DOMElement $element, array $excludedProperties, array $excludedGeometryProperties): array => $this->styleResolver->presentationAttributes($element, $excludedProperties, $excludedGeometryProperties),
+ fn (string $name, array $attributes, array $innerBlocks, ?DOMElement $sourceElement): array => $this->createBlock($name, $attributes, $innerBlocks, $sourceElement),
+ fn (DOMElement $element, array $excludedTags): string => $this->richTextContentWithMaterializedInlineStyles($element, $excludedTags),
+ fn (string $html): string => $this->runtime->stripAllTags($html),
+ fn (string $text): string => $this->runtime->escapeHtml($text),
+ fn (DOMElement $pre, DOMElement $code): array => $this->codePresentationAttributes($pre, $code),
+ fn (DOMElement $code): string => $this->codeContent($code),
+ fn (DOMElement $element): bool => $this->sourceElementClassifier->hasBlockContentChildren($element),
+ function (DOMElement $element, array &$fallbacks, bool $captureUnsupported): array {
+ return $this->convertChildren($element, $fallbacks, $captureUnsupported);
+ }
+ );
+ }
+
+ private function authorStyles(): AuthorStyleAnalysis
+ {
+ return $this->session->authorStyleAnalysis()
+ ?? throw new \LogicException('Author styles have not been prepared for this transform.');
+ }
+
+ private function layoutGeometry(): LayoutGeometryState
+ {
+ return $this->session->layoutGeometryState()
+ ?? throw new \LogicException('Layout geometry state has not been prepared for this transform.');
+ }
+
+ private function transformationProvenance(): TransformationProvenanceState
+ {
+ return $this->session->transformationProvenanceState();
+ }
+
+ private function transformationEvidence(): TransformationEvidenceState
+ {
+ return $this->session->transformationEvidenceState();
+ }
+
+ private function runtimeBehavior(): RuntimeBehaviorState
+ {
+ return $this->session->runtimeBehaviorState();
+ }
+
+ private function fallbackEmitter(): FallbackEmitter
+ {
+ return $this->session->fallbackEmitter();
+ }
+
+ private function presentationResolutionCache(): PresentationResolutionCache
+ {
+ return $this->session->presentationResolutionCache();
+ }
+
+ private function generatedBlocks(): GeneratedBlockRegistry
+ {
+ return $this->session->generatedBlockRegistry()
+ ?? throw new \LogicException('Generated block registry has not been prepared for this transform.');
+ }
+
+ private function materializedAssets(): AssetMaterializationState
+ {
+ return $this->session->assetMaterializationState()
+ ?? throw new \LogicException('Asset materialization state has not been prepared for this transform.');
+ }
+
+ private function runtimeDom(): RuntimeDomState
+ {
+ return $this->session->runtimeDomState();
+ }
+
+ private function runtimeSelectors(): RuntimeSelectorState
+ {
+ return $this->session->runtimeSelectorState();
+ }
+
+ private function navigationProjection(): NavigationProjectionState
+ {
+ return $this->session->navigationProjectionState();
+ }
+
+ private function sourceStyles(): SourceStyleResolutionState
+ {
+ return $this->session->sourceStyleResolutionState();
+ }
+
+ private function reusableComponents(): ReusableComponentState
+ {
+ return $this->session->reusableComponentState();
+ }
+
+ private function authorSelectorProjections(): AuthorSelectorProjectionState
+ {
+ return $this->session->authorSelectorProjectionState();
+ }
+
+ private function generatedSupportStyles(): GeneratedSupportStylesheetState
+ {
+ return $this->session->generatedSupportStylesheetState();
+ }
+
+ private function sourceBlockAttributeProjectionContext(): SourceBlockAttributeProjectionContext
+ {
+ return new SourceBlockAttributeProjectionContext(
+ $this->authorStyles(),
+ $this->authorSelectorProjections(),
+ $this->generatedSupportStyles()
+ );
+ }
+
+ protected function fallbackSourceTagMarker(string $tagName): string
+ {
+ return $this->authorSelectorProjections()->tagMarker($tagName);
+ }
+
+ /**
+ * @param array $options
+ */
+ public function transform(string $html, array $options = array()): TransformerResult
+ {
+ $context = TransformationOptions::context($options);
+ $startedAt = hrtime(true);
+ $this->transformationProvenance()->installFallback(TransformationOptions::provenance($options));
+ $this->session->installGeneratedBlockRegistry(new GeneratedBlockRegistry($this->generatedBlockNamespaceFromOptions($options)));
+ $this->session->installAssetMaterializationState(new AssetMaterializationState(
+ trim((string) ($options['generated_asset_root'] ?? ''), '/'),
+ $this->assetMetadataFromOptions($options)
+ ));
+ $this->session->configurePolicy(! empty($options['extract_global_shell']), ! empty($options['fallback_reduction_mode']));
+ $this->runtimeBehavior()->installRuntimeScriptMetadata($this->runtimeIslands->runtimeScriptMetadataFromOptions($options));
+ $this->runtimeBehavior()->installRuntimeProjectionScriptAssets(
+ is_array($options['runtime_projection_script_assets'] ?? null) ? $options['runtime_projection_script_assets'] : array()
+ );
+ $staticCss = (string) ($options['static_css'] ?? '');
+ $styleAnalysis = $this->stylesheetAnalysisComposer->composedStyleAnalysis(
+ $this->stylesheetAnalysisComposer->stylesheetPayloads($html, $staticCss, $options)
+ );
+ $this->sourceStyles()->installStylesheetAnalysis($this->detectStaticClassPromotions($html), $styleAnalysis);
+ $runtimeDomSelectors = $this->runtimeIslands->runtimeSelectorsFromOptions($options, 'runtime_dom_selectors');
+ $this->session->installRuntimeSelectorState(new RuntimeSelectorState(
+ $runtimeDomSelectors,
+ $this->runtimeIslands->runtimeSelectorsFromOptions($options, 'runtime_behavioral_selectors'),
+ $this->runtimeIslands->runtimeCanvasSelectorsFromOptions($options)
+ ));
+ $this->session->installLayoutGeometryState(new LayoutGeometryState(
+ is_array($options['layout_geometry_proof']['reductions'] ?? null) ? $options['layout_geometry_proof']['reductions'] : array()
+ ));
+ $provenance = array(
+ array_merge(array(
+ 'source_format' => 'html',
+ 'input_bytes' => strlen($html),
+ 'transformer' => HtmlTransformer::class,
+ ), $this->transformationProvenance()->fallback()),
+ );
+
+ $sourceBodyClasses = $this->documentBodyClassNames($html);
+ $normalizedHtml = $this->normalizeHtml5VoidElements($this->documentBodyHtml($this->normalizeExplicitPlaintextElements($html)));
+ $document = new DOMDocument();
+ $previous = libxml_use_internal_errors(true);
+ $loaded = $document->loadHTML('' . $normalizedHtml . '', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
+ libxml_clear_errors();
+ libxml_use_internal_errors($previous);
+
+ if ( ! $loaded ) {
+ $diagnostics = array(
+ array(
+ 'code' => 'html_parse_failed',
+ 'message' => 'Unable to parse HTML input.',
+ 'source' => HtmlTransformer::class,
+ ),
+ );
+ $fallbacks = array(
+ FallbackDiagnostic::build(array(
+ 'type' => 'html',
+ 'reason' => 'parse_failed',
+ 'diagnostic_code' => 'html_parse_failed',
+ 'source_format' => 'html',
+ 'html' => $html,
+ ), $this->transformationProvenance()->fallback()),
+ );
+
+ $metrics = $this->metrics($html, array(), '', $fallbacks, $diagnostics, $startedAt);
+ $sourceReports = array(
+ 'conversion_report' => ConversionReportProjection::fromResultParts('html', array(), $fallbacks, array(), array(), $provenance, $metrics),
+ );
+
+ return new TransformerResult(
+ diagnostics: $diagnostics,
+ sourceReports: $sourceReports,
+ fallbacks: $fallbacks,
+ provenance: $provenance,
+ context: $context,
+ metrics: $metrics
+ );
+ }
+
+ $body = $document->getElementsByTagName('body')->item(0);
+ if ( ! $body instanceof DOMElement ) {
+ $metrics = $this->metrics($html, array(), '', array(), array(), $startedAt);
+ $sourceReports = array(
+ 'conversion_report' => ConversionReportProjection::fromResultParts('html', array(), array(), array(), array(), $provenance, $metrics),
+ );
+
+ return new TransformerResult(
+ sourceReports: $sourceReports,
+ provenance: $provenance,
+ context: $context,
+ metrics: $metrics
+ );
+ }
+
+ if ( array() !== $sourceBodyClasses ) {
+ $body->setAttribute('class', implode(' ', $sourceBodyClasses));
+ }
+
+ $this->navigationBlockNormalizer->hydrateDuplicateSubmenus($body);
+ $this->materializeDeclarativeCounters($body, (string) ($options['declarative_state_html'] ?? ''));
+ // Remove wrapper-convention custom elements before author selectors are
+ // prepared, so style projection and component promotion both observe the
+ // content rather than the source's wrapping convention.
+ $this->unwrapRenderNeutralCustomElements($body, $this->authoredCssText($html, (string) ($options['static_css'] ?? '')));
+ $this->prepareAuthorSelectorSemantics($html, (string) ($options['static_css'] ?? ''), $body, $options);
+ $this->fallbackEmitter()->configure($this->transformationProvenance()->fallback(), $this->runtimeBehavior()->runtimeScriptMetadata(), $this->runtimeSelectors(), $this->authorSelectorProjections()->tagMarkers());
+ // Author-selector preparation marks source nodes for later projection.
+ // General style matching begins only after those source mutations settle.
+ $this->sourceStyles()->invalidateSelectorMatches();
+ $this->styleResolver->collectEditorHiddenStateFindings($body);
+ $this->reusableComponents()->installRecognition($this->reusableComponentRecognizer->recognize($body));
+
+ $fallbacks = array();
+ $interactionCandidates = $this->interactionCandidates($body);
+ $this->navigationToggleSuppressor->collectProjectedNavigationRelationships($body);
+ $this->navigationToggleSuppressor->collectSupersededNavToggleSelectors($body);
+ $shellArtifacts = !array_key_exists('extract_global_shell', $options) || !empty($options['extract_global_shell']) ? $this->globalShellArtifacts($body, (string) ($options['source'] ?? 'html')) : array();
+ $this->collectGeneratedComponentCandidates($body);
+ $blocks = $this->navigationBlockNormalizer->normalize($this->convertChildren($body, $fallbacks, true), $this->transformationProvenance()->sources(), $this->transformationProvenance()->sourceBaseHiddenStates());
+ $blocks = $this->compressProjectedGroupChains($blocks);
+ $fallbacks = array_merge($fallbacks, $this->transformationEvidence()->responsiveImageFallbacks());
+ if (! $this->session->usesFallbackReductionMode()) {
+ $blocks = $this->reduceCoreHtmlFallbackBlocks($blocks);
+ }
+ $this->runtimeIslands->recordRuntimeIslandsForPreservedHtmlBlocks($blocks);
+ $this->appendInteractiveControlBehaviorLossFallbacks($body, $fallbacks);
+ $this->appendProductGridFallbacks($body, $fallbacks, $blocks);
+ $this->appendCommerceControlsFallbacks($body, $fallbacks);
+ $serializedBlocks = $this->runtime->serializeBlocks($blocks);
+ $this->finalizeFallbackBindings($fallbacks, $blocks, $serializedBlocks);
+ $reusableComponentRecognition = $this->reusableComponents()->report($this->materializedAssets()->assets());
+ $sourceProvenance = $this->transformationProvenance()->resolveBlockPaths($blocks);
+ $authorStylesheetProjections = $this->authorStylesheetProjections();
+ $runtimeScriptProjections = $this->runtimeScriptProjections();
+ $this->materializeAuthorStylesheet(
+ $html,
+ (string) ($options['static_css'] ?? ''),
+ true !== ($options['skip_author_stylesheet_materialization'] ?? false),
+ $serializedBlocks,
+ $sourceProvenance,
+ $authorStylesheetProjections
+ );
+ $this->navigationStyleProjector->materializeEditorStaticStateStylesheet();
+ $blockValidityReport = $this->runtime->validateBlockSerialization($blocks);
+ $semanticParityReport = $this->semanticParityReporter->report($body, $blocks, $sourceProvenance, $html, (string) ($options['static_css'] ?? ''));
+ $contentRoundTripReport = $this->contentRoundTripReporter->report($serializedBlocks, $html, $this->transformationEvidence()->formControlEchoTexts());
+ $diagnostics = $this->diagnosticsCollector->collect(
+ HtmlTransformer::class,
+ $this->runtimeBehavior()->scriptMetadata(),
+ $fallbacks,
+ $this->runtimeDom()->islands(),
+ $this->runtimeDom()->preservations(),
+ $this->runtimeDom()->fallbacks(),
+ $blockValidityReport,
+ $semanticParityReport,
+ $contentRoundTripReport
+ );
+ foreach ( $this->transformationEvidence()->responsiveGeometryAmbiguities() as $ambiguity ) {
+ $diagnostics[] = array(
+ 'code' => 'responsive_geometry_ambiguous_min_width',
+ 'message' => 'A wide minimum-width rule matches both page-shell and authored content surfaces, so it was retained without a responsive projection.',
+ 'source' => HtmlTransformer::class,
+ 'severity' => 'warning',
+ 'selector' => $ambiguity['selector'],
+ 'min_width' => $ambiguity['min_width'],
+ );
+ }
+ foreach ( $this->transformationEvidence()->responsiveHeightAmbiguities() as $ambiguity ) {
+ $diagnostics[] = array(
+ 'code' => 'responsive_geometry_ambiguous_percentage_height',
+ 'message' => 'A percentage-height rule matches both auto-sized structural wrappers and height-owning content, so it was retained without a responsive projection.',
+ 'source' => HtmlTransformer::class,
+ 'severity' => 'warning',
+ 'selector' => $ambiguity['selector'],
+ 'height' => $ambiguity['height'],
+ );
+ }
+ $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' => HtmlTransformer::class,
+ 'severity' => 'info',
+ 'entries' => $headMetadata,
+ );
+ }
+ $authorLayoutTopologyFindings = $this->transformationEvidence()->authorLayoutTopologyFindings();
+ foreach ( $authorLayoutTopologyFindings as $finding ) {
+ $diagnostics[] = array(
+ 'code' => 'author_layout_topology_changed',
+ 'message' => 'Gutenberg block conversion changed the direct-child topology of a CSS-owned layout container.',
+ 'source' => HtmlTransformer::class,
+ 'severity' => 'warning',
+ 'selector' => $finding['selector'],
+ 'source_child_count' => $finding['source_child_count'],
+ 'block_child_count' => $finding['block_child_count'],
+ );
+ }
+ if ( $this->generatedBlocks()->has(DescriptionListBlockGenerator::class) ) {
+ $diagnostics[] = array(
+ 'code' => 'semantic_description_list_gutenberg_gap',
+ 'message' => 'A semantic description list was materialized with the Blocks Engine companion block because Gutenberg has no core description-list block.',
+ 'source' => HtmlTransformer::class,
+ 'severity' => 'info',
+ 'references' => array(
+ 'https://github.com/WordPress/gutenberg/issues/4880',
+ 'https://github.com/WordPress/gutenberg/pull/20760',
+ ),
+ );
+ }
+
+ $this->styleResolver->recordSourceSelectorMatchWork();
+ $metrics = $this->metrics($html, $blocks, $serializedBlocks, $fallbacks, $diagnostics, $startedAt);
+ $nativeTargetBlocks = $this->runtime->availableCoreBlockNames();
+ $capabilityMatrix = (new CoreBlockCapabilityMatrix())->coverage($nativeTargetBlocks);
+ $supportedBlocks = $capabilityMatrix['supported_blocks'];
+ $runtimeBlockPaths = array_values(array_filter(array_map(static fn (array $entry): string => !empty($entry['editability_runtime_owned']) ? (string) ($entry['block_path'] ?? '') : '', $sourceProvenance)));
+ $visualBlockPaths = array_values(array_filter(array_map(static fn (array $entry): string => !empty($entry['editability_visual_owned']) ? (string) ($entry['block_path'] ?? '') : '', $sourceProvenance)));
+ $generatedCarrierCss = $this->engineSupportCss();
+ $sourceReports = array(
+ 'native_target_blocks' => $nativeTargetBlocks,
+ 'available_core_blocks' => $nativeTargetBlocks,
+ 'core_block_capabilities' => $capabilityMatrix,
+ 'head_metadata' => $headMetadata,
+ 'runtime_islands' => $this->runtimeDom()->islands(),
+ 'runtime_dom_contracts' => $this->runtimeDom()->preservations(),
+ 'runtime_dom_fallbacks' => $this->runtimeDom()->fallbacks(),
+ 'generated_blocks' => $this->generatedBlocks()->definitions(),
+ 'gutenberg_gaps' => $this->generatedBlocks()->has(DescriptionListBlockGenerator::class) ? array(
+ array(
+ 'id' => 'semantic-description-list',
+ 'block_name' => DescriptionListBlockGenerator::NAME,
+ 'references' => array(
+ 'https://github.com/WordPress/gutenberg/issues/4880',
+ 'https://github.com/WordPress/gutenberg/pull/20760',
+ ),
+ ),
+ ) : array(),
+ 'interaction_candidates' => $interactionCandidates,
+ 'superseded_selectors' => $this->runtimeSelectors()->supersededSelectors(),
+ 'shell_artifacts' => $shellArtifacts,
+ 'wp_block_validity' => $blockValidityReport,
+ 'semantic_parity' => $semanticParityReport,
+ 'content_round_trip' => $contentRoundTripReport,
+ 'editability_report' => (new EditabilityReport())->fromBlocks($blocks, (string) ($options['source'] ?? ''), $serializedBlocks, $generatedCarrierCss, $runtimeBlockPaths, $visualBlockPaths, $sourceProvenance),
+ 'html' => array(
+ 'presentation_signals' => $this->transformationProvenance()->presentationSignals(),
+ 'frozen_hidden_state' => $this->transformationEvidence()->frozenHiddenStateFindings(),
+ 'dropped_link_wrappers' => $this->transformationEvidence()->droppedLinkWrapperFindings(),
+ 'gutenberg_incompatibilities' => $this->transformationEvidence()->gutenbergIncompatibilities(),
+ 'author_layout_topology' => $authorLayoutTopologyFindings,
+ 'source_provenance' => $sourceProvenance,
+ 'core_html_fallback_evidence' => CoreHtmlFallbackEvidence::fromBlocks($blocks, $fallbacks, $sourceProvenance),
+ 'structure_signals' => $this->transformationProvenance()->structureSignals(),
+ 'reusable_components' => $reusableComponentRecognition,
+ 'script_metadata' => $this->runtimeBehavior()->scriptMetadata(),
+ 'runtime_islands' => $this->runtimeDom()->islands(),
+ 'layout_geometry_proof' => $this->layoutGeometry()->proofProvenance(),
+ ),
+ );
+ if ( array() !== $authorStylesheetProjections ) {
+ $sourceReports['author_stylesheet_projections'] = $authorStylesheetProjections;
+ }
+ if ( array() !== $runtimeScriptProjections ) {
+ $sourceReports['runtime_script_projections'] = $runtimeScriptProjections;
+ }
+ $sourceReports['conversion_report'] = ConversionReportProjection::fromResultParts('html', $blocks, $fallbacks, $sourceReports, array(), $provenance, $metrics);
+
+ return new TransformerResult(
+ status: $this->statusForFallbacks($fallbacks, $context),
+ blocks: $blocks,
+ serializedBlocks: $serializedBlocks,
+ assets: $this->materializedAssets()->assets(),
+ diagnostics: $diagnostics,
+ fallbacks: $fallbacks,
+ provenance: $provenance,
+ sourceReports: $sourceReports,
+ coverage: array(
+ array(
+ 'supported_blocks' => $supportedBlocks,
+ 'runtime_available_blocks' => $nativeTargetBlocks,
+ 'capability_matrix' => $capabilityMatrix,
+ 'block_count' => count($blocks),
+ 'fallback_count' => count($fallbacks),
+ 'source_provenance_count' => count($sourceProvenance),
+ ),
+ ),
+ context: $context,
+ metrics: $metrics
+ );
+ }
+
+ private function engineSupportCss(): string
+ {
+ $css = array();
+ foreach ($this->materializedAssets()->assets() as $asset) if ('engine-support' === ($asset['source'] ?? '') && 'css' === ($asset['kind'] ?? '') && is_string($asset['content'] ?? null)) $css[] = $asset['content'];
+ return implode("\n", $css);
+ }
+
+ /**
+ * Reduce safe legacy core/html islands through the producer's native block
+ * recognizers. An island is replaced only when its complete fragment maps
+ * to native blocks with no fallback diagnostics; otherwise its serialized
+ * payload remains untouched.
+ *
+ * @param array> $blocks
+ * @return array>
+ */
+ public function reduceCoreHtmlFallbackBlocks(array $blocks): array
+ {
+ $reduced = array();
+ foreach ($blocks as $block) {
+ if (!is_array($block)) {
+ continue;
+ }
+
+ $name = is_string($block['blockName'] ?? null) ? $block['blockName'] : '';
+ if (in_array($name, array('core/html', 'core/freeform'), true)) {
+ $html = is_string($block['attrs']['content'] ?? null)
+ ? $block['attrs']['content']
+ : (is_string($block['innerHTML'] ?? null) ? $block['innerHTML'] : '');
+ $replacement = $this->safeFallbackFragmentBlocks($html);
+ array_push($reduced, ...($replacement ?? array($block)));
+ continue;
+ }
+
+ $children = is_array($block['innerBlocks'] ?? null) ? $block['innerBlocks'] : array();
+ if (array() !== $children) {
+ $reducedChildren = array();
+ $childReplacements = array();
+ foreach ($children as $child) {
+ $replacement = $this->reduceCoreHtmlFallbackBlocks(array($child));
+ $childReplacements[] = $replacement;
+ array_push($reducedChildren, ...$replacement);
+ }
+ $block['innerBlocks'] = $reducedChildren;
+ $innerContent = array();
+ $childIndex = 0;
+ foreach (is_array($block['innerContent'] ?? null) ? $block['innerContent'] : array() as $content) {
+ if (null !== $content) {
+ $innerContent[] = $content;
+ continue;
+ }
+ foreach ($childReplacements[$childIndex] ?? array() as $_) {
+ $innerContent[] = null;
+ }
+ ++$childIndex;
+ }
+ $block['innerContent'] = $innerContent;
+ $block['innerHTML'] = implode('', array_filter($innerContent, 'is_string'));
+ }
+ $reduced[] = $block;
+ }
+
+ return $reduced;
+ }
+
+ /**
+ * @return array>|null Null keeps the original island.
+ */
+ private function safeFallbackFragmentBlocks(string $html): ?array
+ {
+ if ('' === trim($html)) {
+ return array();
+ }
+ if (preg_match('/<\s*(?:script|style|iframe|canvas|svg|form|input|select|textarea)\b/i', $html)) {
+ return null;
+ }
+ $document = new DOMDocument();
+ $previous = libxml_use_internal_errors(true);
+ $loaded = $document->loadHTML('' . $html . '');
+ libxml_clear_errors();
+ libxml_use_internal_errors($previous);
+ if ($loaded) {
+ foreach ($document->getElementsByTagName('*') as $element) {
+ if ($element instanceof DOMElement && $this->runtimeIslands->isRuntimeDomTarget($element)) {
+ return null;
+ }
+ }
+ }
+
+ $result = (new self($this->runtime, $this->analysisCache))->transform($html, array('extract_global_shell' => false, 'fallback_reduction_mode' => true));
+ $data = $result->toArray();
+ $blocks = is_array($data['blocks'] ?? null) ? $data['blocks'] : array();
+ if (array() === $blocks || array() !== ($data['fallbacks'] ?? array())) {
+ return null;
+ }
+ foreach ($blocks as $block) {
+ if (!is_array($block) || !str_starts_with((string) ($block['blockName'] ?? ''), 'core/') || in_array($block['blockName'] ?? '', array('core/html', 'core/freeform'), true)) {
+ return null;
+ }
+ }
+
+ return $blocks;
+ }
+
+ /**
+ * Convert reusable document shell interiors through the same transformer
+ * state as the full page so projected selector identities remain canonical.
+ *
+ * @return array>
+ */
+ private function globalShellArtifacts(DOMElement $body, string $source, bool $removeFromContent = false): array
+ {
+ $artifacts = array();
+ $removals = array();
+ foreach ( $body->childNodes as $child ) {
+ if ( ! $child instanceof DOMElement ) {
+ continue;
+ }
+ $area = ShellLandmarkPolicy::landmarkKind(strtolower($child->tagName), $this->attr($child, 'role'));
+ if ( ! in_array($area, array( 'header', 'footer' ), true) ) {
+ continue;
+ }
+
+ $shellFallbacks = array();
+ $blocks = $this->navigationBlockNormalizer->normalize($this->convertChildren($child, $shellFallbacks, true), $this->transformationProvenance()->sources(), $this->transformationProvenance()->sourceBaseHiddenStates());
+ $innerMarkup = $this->runtime->serializeBlocks($blocks);
+ $wrapperAttrs = $this->hoistedStylingAttributes($child);
+ $wrapperAttrs['tagName'] = $area;
+ $inlineStyle = trim($this->attr($child, 'style'));
+ if ( '' !== $inlineStyle ) {
+ // Group support maps only its canonical subset; retain the source
+ // declaration so the landmark wrapper still owns its visual hook.
+ $wrapperAttrs['inlineGeometryStyle'] = $inlineStyle;
+ }
+ $anchor = trim($this->attr($child, 'id'));
+ if ( '' !== $anchor ) {
+ $wrapperAttrs['anchor'] = $anchor;
+ }
+ // Use one core/group landmark wrapper rather than nesting the source
+ // landmark around an independently converted landmark block.
+ $blocks = array($this->createBlock('core/group', $wrapperAttrs, $blocks, $child));
+ $markup = $this->runtime->serializeBlocks($blocks);
+ $templatePartAttrs = $wrapperAttrs;
+ unset($templatePartAttrs['tagName']);
+ $templatePartMarkup = array() === $templatePartAttrs
+ ? $innerMarkup
+ : $this->runtime->serializeBlocks(array($this->createBlock('core/group', $templatePartAttrs, $blocks[0]['innerBlocks'] ?? array())));
+ if ( '' === trim($markup) ) {
+ continue;
+ }
+ $artifacts[] = array(
+ 'source_path' => $source . '#' . $area,
+ 'slug' => $area,
+ 'title' => ucfirst($area),
+ 'area' => $area,
+ 'body_format' => 'blocks',
+ 'block_markup' => $markup,
+ 'inner_block_markup' => $innerMarkup,
+ 'template_part_block_markup' => $templatePartMarkup,
+ 'source_selector' => strtolower($child->tagName),
+ 'source_classes' => $this->shellSourceClasses($child),
+ 'source_hash' => hash('sha256', $this->outerHtml($child)),
+ 'placement' => array('kind' => 'entry_shell', 'source_path' => $source, 'template_slugs' => array('front-page')),
+ );
+ // A successfully projected global shell is owned by the template part,
+ // not duplicated in the entry page's post-content markup.
+ if ($removeFromContent) $removals[] = $child;
+ }
+
+ foreach ($removals as $child) $body->removeChild($child);
+
+ return $artifacts;
+ }
+
+ /** @return array */
+ private function shellSourceClasses(DOMElement $element): array
+ {
+ $classes = preg_split('/\s+/', trim($this->attr($element, 'class'))) ?: array();
+ $classes = array_values(array_unique(array_filter($classes, static fn (string $class): bool => '' !== $class)));
+ sort($classes, SORT_STRING);
+ return $classes;
+ }
+
+ /**
+ * @param array> $blocks
+ * @param array> $fallbacks
+ * @param array> $diagnostics
+ * @return array
+ */
+ private function metrics(string $input, array $blocks, string $output, array $fallbacks, array $diagnostics, int $startedAt): array
+ {
+ $selectorCache = $this->sourceStyles()->selectorMatchCache;
+ return array(
+ 'input_bytes' => strlen($input),
+ 'block_count' => $this->countBlocks($blocks),
+ 'fallback_count' => count($fallbacks),
+ 'diagnostic_count' => count($diagnostics),
+ 'transform_duration_ms' => (hrtime(true) - $startedAt) / 1000000,
+ 'output_bytes' => strlen($output),
+ 'selector_match_cache_hits' => $selectorCache->matchHits,
+ 'selector_match_cache_misses' => $selectorCache->matchMisses,
+ 'selector_match_cache_evictions' => $selectorCache->matchEvictions,
+ 'selector_match_cache_peak_entries' => $selectorCache->matchPeakEntries,
+ 'style_rule_candidate_cache_hits' => $selectorCache->candidateRuleHits,
+ 'style_rule_candidate_cache_misses' => $selectorCache->candidateRuleMisses,
+ 'style_rule_candidate_cache_evictions' => $selectorCache->candidateRuleEvictions,
+ 'style_rule_candidate_cache_peak_entries' => $selectorCache->candidateRulePeakEntries,
+ 'style_rule_candidate_cache_peak_rule_references' => $selectorCache->candidateRulePeakRetained,
+ );
+ }
+
+ private function reusableComponentFingerprintFor(DOMElement $element): ?string
+ {
+ return $this->reusableComponents()->fingerprintForPath((string) $element->getNodePath());
+ }
+
+ /**
+ * Remove custom elements a source uses as a wrapping convention.
+ *
+ * Site builders wrap ordinary content in presentation-only custom elements.
+ * A custom element tag is not evidence that its subtree needs a generated
+ * block, so leaving those wrappers in place freezes headings and copy into
+ * opaque markup. A tag is treated as a convention when it recurs across the
+ * document while the content it wraps diverges: a genuine component repeats
+ * its internal structure, a wrapper does not.
+ */
+ private function unwrapRenderNeutralCustomElements(DOMElement $body, string $staticCss): void
+ {
+ $instances = array();
+ foreach ( $body->getElementsByTagName('*') as $element ) {
+ if ( $element instanceof DOMElement && str_contains(strtolower($element->tagName), '-') ) {
+ $instances[strtolower($element->tagName)][] = $element;
+ }
+ }
+
+ foreach ( $instances as $tag => $elements ) {
+ if ( ! $this->isWrapperConventionTag($tag, $elements, $staticCss) ) {
+ continue;
+ }
+ foreach ( $elements as $element ) {
+ if ( $this->isRenderNeutralWrapper($element) ) {
+ $this->unwrapElement($element);
+ }
+ }
+ }
+ }
+
+ /**
+ * Whether a custom element tag is used as a wrapping convention rather than
+ * as a component host.
+ *
+ * @param array $elements Every instance of the tag.
+ */
+ private function isWrapperConventionTag(string $tag, array $elements, string $staticCss): bool
+ {
+ if ( self::WRAPPER_CONVENTION_MIN_INSTANCES > count($elements) ) {
+ return false;
+ }
+
+ // A tag that carries presentation of its own is not render-neutral.
+ // `display:contents` is the exception that proves the rule: it states
+ // the element generates no box and renders as its children.
+ if ( ! $this->customElementCssIsRenderNeutral($tag, $staticCss) ) {
+ return false;
+ }
+
+ $shapes = array();
+ foreach ( $elements as $element ) {
+ $shapes[$this->wrappedContentShape($element)] = true;
+ if ( 1 < count($shapes) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Author CSS visible to the document: declared stylesheets plus the styles
+ * the source embeds inline.
+ */
+ private function authoredCssText(string $html, string $staticCss): string
+ {
+ $embedded = array();
+ if ( 0 < preg_match_all('/Read this.
')->toArray();
$assert(str_contains($css($third), 'blocks-engine-richtext-') && ! str_contains($css($third), '> :where(.wp-block-button__link)'), 'repeated selector text resolves against each transform source DOM');
-$applicabilityTransformer = new HtmlTransformer();
-$applicabilityTransformer->transform('Present
');
-$applicabilitySession = (new ReflectionClass($applicabilityTransformer))->getProperty('session')->getValue($applicabilityTransformer);
+$applicabilityCompilation = new HtmlCompilation();
+$applicabilityCompilation->transform('Present
');
+$applicabilitySession = (new ReflectionClass($applicabilityCompilation))->getProperty('session')->getValue($applicabilityCompilation);
$applicableRules = $applicabilitySession->authorStyleAnalysis()?->styleRules() ?? array();
$applicableSelectors = array_column(array_merge(...array_column($applicableRules, 'selectors')), 'selector');
$assert(array('.present') === $applicableSelectors, 'the installed page-matching graph omits selectors whose required source signals are absent');
-$unmatchableTransformer = new HtmlTransformer();
-$unmatchableTransformer->transform('');
-$unmatchableSession = (new ReflectionClass($unmatchableTransformer))->getProperty('session')->getValue($unmatchableTransformer);
+$unmatchableCompilation = new HtmlCompilation();
+$unmatchableCompilation->transform('');
+$unmatchableSession = (new ReflectionClass($unmatchableCompilation))->getProperty('session')->getValue($unmatchableCompilation);
$unmatchableIndex = $unmatchableSession->authorStyleAnalysis()?->styleRuleCandidateIndex() ?? array();
$indexedAuthorSelectors = array();
foreach ( array( 'universal', 'ids', 'classes', 'tags', 'attributes' ) as $bucket ) {
diff --git a/php-transformer/tests/unit/block-style-support-conversion.php b/php-transformer/tests/unit/block-style-support-conversion.php
index 018dd052..59ed1176 100644
--- a/php-transformer/tests/unit/block-style-support-conversion.php
+++ b/php-transformer/tests/unit/block-style-support-conversion.php
@@ -8,6 +8,7 @@
require dirname(__DIR__, 2) . '/vendor/autoload.php';
+use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\HtmlCompilation;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\HtmlTransformer;
use Automattic\BlocksEngine\PhpTransformer\VisualParity\StaticStyleParityRunner;
use Automattic\BlocksEngine\PhpTransformer\VisualParity\StaticStyleParityComparator;
@@ -363,8 +364,8 @@
$assert(! isset($paintAttrs['style']['background-position']) && ! isset($paintAttrs['style']['background-size']), '41: background layer controls stay out of block style attrs', json_encode($paintAttrs['style'] ?? array()));
// Style resolution moved to StyleResolver under #242.
-$styleResolverProperty = new ReflectionProperty(HtmlTransformer::class, 'styleResolver');
-$paintRules = $styleResolverProperty->getValue(new HtmlTransformer())->stylesheetAnalysis($paintCss)['static'];
+$styleResolverProperty = new ReflectionProperty(HtmlCompilation::class, 'styleResolver');
+$paintRules = $styleResolverProperty->getValue(new HtmlCompilation())->stylesheetAnalysis($paintCss)['static'];
$paintDeclarations = $paintRules[0]['declarations'] ?? array();
$assert(($paintDeclarations['background'] ?? '') === 'radial-gradient(circle at 20% 10%,rgba(255,255,255,.9),rgba(255,255,255,0) 38%),linear-gradient(180deg,#fff,#f5efe4)', '42: radial and layered backgrounds survive safe CSS resolution', json_encode($paintDeclarations));
diff --git a/php-transformer/tests/unit/html-transformer-session-state.php b/php-transformer/tests/unit/html-transformer-session-state.php
index 625ca575..2bfcf84a 100644
--- a/php-transformer/tests/unit/html-transformer-session-state.php
+++ b/php-transformer/tests/unit/html-transformer-session-state.php
@@ -95,6 +95,15 @@
$sessionReflection = new ReflectionClass(HtmlTransformerSession::class);
$transformerReflection = new ReflectionClass(HtmlTransformer::class);
$assert(array() === $sessionReflection->getProperties(ReflectionProperty::IS_PUBLIC), 'Transform session state must remain encapsulated behind typed lifecycle APIs.');
+$transformerProperties = array_map(
+ static fn (ReflectionProperty $property): string => $property->getName(),
+ $transformerReflection->getProperties()
+);
+sort($transformerProperties);
+$assert(
+ array('analysisCache', 'runtime') === $transformerProperties,
+ 'HtmlTransformer must remain a stateless facade over immutable shared inputs.'
+);
foreach ( array('__get', '__set', '__isset') as $magicAccessor ) {
$assert(! $transformerReflection->hasMethod($magicAccessor), 'HtmlTransformer must not delegate state through ' . $magicAccessor . '.');
}
diff --git a/php-transformer/tests/unit/media-text-pattern.php b/php-transformer/tests/unit/media-text-pattern.php
index 5005306f..07db06c1 100644
--- a/php-transformer/tests/unit/media-text-pattern.php
+++ b/php-transformer/tests/unit/media-text-pattern.php
@@ -3,6 +3,7 @@
require dirname(__DIR__, 2) . '/vendor/autoload.php';
+use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\HtmlCompilation;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\HtmlTransformer;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns\MediaTextPattern;
use Automattic\BlocksEngine\PhpTransformer\WordPress\Runtime;
@@ -944,17 +945,17 @@ static function (string $name, array $attrs, array $innerBlocks, ?DOMElement $so
$assertContains('has-media-on-the-right', (string) ($roundTrip['innerHTML'] ?? ''), 'Round-trip save shape restores right class.');
// Media-text style resolution memoizes by the shared presentation cache key.
-$memoizedTransformer = new HtmlTransformer();
+$memoizedCompilation = new HtmlCompilation();
$memoizedElement = $elementFromHtml('');
// Style resolution moved to StyleResolver under #242; reach it through the
-// transformer's collaborator rather than reflecting on the transformer.
-$styleResolverProperty = new ReflectionProperty(HtmlTransformer::class, 'styleResolver');
-$memoizedResolver = $styleResolverProperty->getValue($memoizedTransformer);
-$sessionProperty = new ReflectionProperty(HtmlTransformer::class, 'session');
+// run-scoped compilation collaborator rather than the public facade.
+$styleResolverProperty = new ReflectionProperty(HtmlCompilation::class, 'styleResolver');
+$memoizedResolver = $styleResolverProperty->getValue($memoizedCompilation);
+$sessionProperty = new ReflectionProperty(HtmlCompilation::class, 'session');
$firstMediaStyle = $memoizedResolver->mediaTextPresentationStyle($memoizedElement);
$memoizedElement->setAttribute('style', 'display:grid');
$secondMediaStyle = $memoizedResolver->mediaTextPresentationStyle($memoizedElement);
-$presentationCache = $sessionProperty->getValue($memoizedTransformer)->presentationResolutionCache();
+$presentationCache = $sessionProperty->getValue($memoizedCompilation)->presentationResolutionCache();
$mediaStyleCache = $presentationCache->mediaTextStyles;
$presentationKey = $presentationCache->elementKey($memoizedElement);
$assertSame('display:flex', $firstMediaStyle, 'Media-text presentation style resolves initial authored style.');
diff --git a/php-transformer/tests/unit/navigation-author-rule-memory.php b/php-transformer/tests/unit/navigation-author-rule-memory.php
index e89256ad..f5954aba 100644
--- a/php-transformer/tests/unit/navigation-author-rule-memory.php
+++ b/php-transformer/tests/unit/navigation-author-rule-memory.php
@@ -5,12 +5,12 @@
require dirname(__DIR__, 2) . '/vendor/autoload.php';
-use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\HtmlTransformer;
+use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\HtmlCompilation;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\AuthorStyleAnalysis;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\CssSelectorMatcher;
-$transformer = new HtmlTransformer();
-$reflection = new ReflectionClass($transformer);
+$compilation = new HtmlCompilation();
+$reflection = new ReflectionClass($compilation);
$css = '@keyframes inert{' . str_repeat('x', 32 * 1024 * 1024) . '}'
. '@media (min-width:1px){.menu li a:hover{color:#123456}}';
$document = new DOMDocument();
@@ -19,7 +19,7 @@
if ( ! $body instanceof DOMElement ) {
throw new RuntimeException('Author style fixture did not produce a body element.');
}
-$session = $reflection->getProperty('session')->getValue($transformer);
+$session = $reflection->getProperty('session')->getValue($compilation);
$authorStyles = new AuthorStyleAnalysis($css, $css, array(), $body);
$authorStyles->installStyleRules(array(array(
'order' => 0,
@@ -34,10 +34,9 @@
$session->installAuthorStyleAnalysis($authorStyles);
// Author-rule collection moved to NavigationStyleProjector. Reach it through
-// the transformer's collaborator so this still exercises the real wiring —
-// including the context closure that resolves the running transform's session
-// state — rather than a projector built in isolation.
-$projector = $reflection->getProperty('navigationStyleProjector')->getValue($transformer);
+// the compilation collaborator so this still exercises the real run-scoped
+// wiring rather than a projector built in isolation.
+$projector = $reflection->getProperty('navigationStyleProjector')->getValue($compilation);
$collect = ( new ReflectionClass($projector) )->getMethod('navigationAuthorStyleRules');
$rules = $collect->invoke($projector);
$rule = is_array($rules) ? ($rules[0] ?? array()) : array();
diff --git a/php-transformer/tests/unit/navigation-projection-state-isolation.php b/php-transformer/tests/unit/navigation-projection-state-isolation.php
index 6a27adb9..953ade76 100644
--- a/php-transformer/tests/unit/navigation-projection-state-isolation.php
+++ b/php-transformer/tests/unit/navigation-projection-state-isolation.php
@@ -15,6 +15,7 @@
require dirname(__DIR__, 2) . '/vendor/autoload.php';
+use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\HtmlCompilation;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\HtmlTransformer;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Session\NavigationProjectionState;
@@ -40,7 +41,7 @@
return $css;
};
-$markup = static function (HtmlTransformer $transformer, string $htmlPath) use ($cssFor): string {
+$markup = static function (HtmlTransformer|HtmlCompilation $transformer, string $htmlPath) use ($cssFor): string {
$result = $transformer->transform(
(string) file_get_contents($htmlPath),
array( 'static_css' => $cssFor($htmlPath) )
@@ -66,7 +67,7 @@
// The priming document must genuinely exercise the projection path, otherwise
// the comparison above passes for the wrong reason.
-$probe = new HtmlTransformer();
+$probe = new HtmlCompilation();
$markup($probe, $priming);
$session = ( new ReflectionClass($probe) )->getProperty('session')->getValue($probe);
$state = $session->navigationProjectionState();
@@ -88,9 +89,10 @@
exit(1);
}
-// A fresh transform must not see the writes above.
-$markup($probe, $subject);
-$freshState = ( new ReflectionClass($probe) )->getProperty('session')->getValue($probe)->navigationProjectionState();
+// A fresh compilation must not see the writes above.
+$freshProbe = new HtmlCompilation();
+$markup($freshProbe, $subject);
+$freshState = ( new ReflectionClass($freshProbe) )->getProperty('session')->getValue($freshProbe)->navigationProjectionState();
if ($freshState->hasTargetForControl($control) || $freshState->isSuppressed($control)) {
fwrite(STDERR, "Navigation projection isolation contract failed: state survived a transform.\n");
exit(1);