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('/]*>(.*?)<\/style>/is', $html, $matches) ) { + $embedded = $matches[1]; + } + + return trim($staticCss . "\n" . implode("\n", $embedded)); + } + + /** + * Whether every author rule addressing a custom element tag leaves it + * render-neutral, so replacing instances with their children preserves the + * authored presentation. + */ + private function customElementCssIsRenderNeutral(string $tag, string $css): bool + { + if ( '' === trim($css) ) { + return true; + } + + $pattern = '/(?cssRuleBlocks($css) as $rule ) { + if ( 1 !== preg_match($pattern, $rule['selector']) ) { + continue; + } + foreach ( $this->styleResolver->cssDeclarations($rule['declarations']) as $property => $value ) { + if ( 'display' !== strtolower(trim($property)) || 'contents' !== strtolower(trim($value)) ) { + return false; + } + } + } + + return true; + } + + /** + * Flat selector/declaration pairs for the top-level rules in a stylesheet. + * + * @return array + */ + private function cssRuleBlocks(string $css): array + { + $rules = array(); + if ( 1 !== preg_match_all('/([^{}]+)\{([^{}]*)\}/', $css, $matches, PREG_SET_ORDER) && array() === $matches ) { + return $rules; + } + + foreach ( $matches as $match ) { + $rules[] = array( + 'selector' => trim($match[1]), + 'declarations' => trim($match[2]), + ); + } + + return $rules; + } + + /** + * Shallow tag skeleton of the content a wrapper carries. Instances of a real + * component repeat this shape; instances of a wrapper do not. + */ + private function wrappedContentShape(DOMElement $element, int $depth = 0): string + { + $parts = array(); + foreach ( $element->childNodes as $child ) { + if ( ! $child instanceof DOMElement ) { + continue; + } + $tag = strtolower($child->tagName); + $parts[] = self::WRAPPER_CONTENT_SHAPE_DEPTH > $depth + ? $tag . '(' . $this->wrappedContentShape($child, $depth + 1) . ')' + : $tag; + } + + return implode(',', $parts); + } + + /** + * Whether a wrapper instance carries no identity, behavior, or presentation + * of its own, and can therefore be replaced by its children. + */ + private function isRenderNeutralWrapper(DOMElement $element): bool + { + return null !== $element->parentNode + && '' === trim($this->attr($element, 'id')) + && '' === trim($this->attr($element, 'class')) + && '' === trim($this->attr($element, 'role')) + && '' === trim($this->attr($element, 'style')) + && array() === $this->interactiveAttributes($element) + && ! $this->runtimeIslands->isRuntimeDomTarget($element) + && ! $this->sourceElementClassifier->hasMotionStructureToken($element); + } + + private function collectGeneratedComponentCandidates(DOMElement $element, int $depth = 0): void + { + if (self::GENERATED_COMPONENT_MIN_SOURCE_DEPTH <= $depth + && ('div' === strtolower($element->tagName) || str_contains(strtolower($element->tagName), '-')) + && ($this->sourceElementClassifier->hasRepeatedDirectChildTags($element) || str_contains(strtolower($element->tagName), '-')) + && ($this->fallbackEmitter()->isRepeatableContentComponent($element) || $this->fallbackEmitter()->isSafeCustomElementHost($element)) + ) { + $this->reusableComponents()->markGeneratedCandidate((string) $element->getNodePath()); + return; + } + + foreach ($element->childNodes as $child) { + if ($child instanceof DOMElement) { + $this->collectGeneratedComponentCandidates($child, $depth + 1); + } + } + } + + private function isGeneratedComponentCandidate(DOMElement $element): bool + { + return $this->reusableComponents()->isGeneratedCandidate((string) $element->getNodePath()); + } + + /** @param array{blockName: string, attrs: array} $generated @return array */ + private function generatedComponentBlock(array $generated, DOMElement $element): array + { + $block = $this->createBlock($generated['blockName'], $generated['attrs'], array(), $element); + foreach (array_merge(array($element), $this->descendantElements($element)) as $target) { + if (! $this->runtimeIslands->isRuntimeDomTarget($target)) { + continue; + } + $block['_editability_runtime_owned'] = true; + $this->runtimeIslands->recordNativeRuntimeDomPreservation($target, $generated['blockName']); + } + return $block; + } + + /** + * 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); + } + + /** + * @param array> $sourceProvenance + * @param list $authorStylesheetProjections + */ + private function materializeAuthorStylesheet(string $html, string $staticCss, bool $includeAuthorStyles = true, string $serializedBlocks = '', array $sourceProvenance = array(), array $authorStylesheetProjections = array()): void + { + $beforeAuthorCssParts = array(); + $authorCssParts = array(); + $afterAuthorCssParts = array(); + $authorCss = ''; + if ( $includeAuthorStyles && '' !== $this->authorStyles()->combinedCss() ) { + $authorCss = $this->rewriteAuthorStylesheet($this->authorStyles()->combinedCss()); + $split = ( new CssStylesheetTransformer() )->splitLeadingAtRulePreamble($authorCss); + if ( '' !== trim($split['preamble']) ) { + $authorCssParts[] = $split['preamble']; + } + $authorCss = $split['stylesheet']; + } + $geometryCss = $this->styleResolver->generatedGeometryCss($serializedBlocks); + if ( '' !== $geometryCss ) { + // Important carrier rules precede author CSS: they retain inline + // precedence over normal selectors while authored !important rules + // remain able to override them. + $beforeAuthorCssParts[] = $geometryCss; + } + $markerReset = $this->richTextMarkerResetCss(); + if ( '' !== $markerReset ) { + $beforeAuthorCssParts[] = $markerReset; + } + if ( str_contains($serializedBlocks, self::SYNTHETIC_PARAGRAPH_CLASS) ) { + // A paragraph is required for valid block markup, but phrasing content + // did not have paragraph margins in the source document. + $beforeAuthorCssParts[] = ':root :where(.' . self::SYNTHETIC_PARAGRAPH_CLASS . '){margin-top:0;margin-bottom:0}' + . "\n" . ':root :where(p.' . self::SYNTHETIC_PARAGRAPH_CLASS . '.has-text-color)>a{color:inherit}' + . "\n" . ':where(p.' . self::SYNTHETIC_PARAGRAPH_CLASS . ')>a{text-decoration:underline}' + . "\n" . ':where(p.' . self::SYNTHETIC_PARAGRAPH_CLASS . '.' . self::SYNTHETIC_ANCHOR_UNDECORATED_CLASS . ')>a{text-decoration:none}'; + } + if ( str_contains($serializedBlocks, SourceBlockAttributeProjector::HIDDEN_RICH_TEXT_MARKER_CLASS) ) { + $beforeAuthorCssParts[] = ':root :where(.' . SourceBlockAttributeProjector::HIDDEN_RICH_TEXT_MARKER_CLASS . '){display:none}'; + } + if ( str_contains($serializedBlocks, self::SYNTHETIC_IMAGE_FIGURE_CLASS) ) { + $beforeAuthorCssParts[] = '.' . self::SYNTHETIC_IMAGE_FIGURE_CLASS . '{margin:0}'; + } + if ( str_contains($serializedBlocks, self::BACKGROUND_IMAGE_CLASS) ) { + // The source painted this image as a background, where the element's + // own box decides the size and the image never overflows it. core's + // scale attribute only reaches the image when width and height are + // saved, so the sized cases carry that contract here instead. + $beforeAuthorCssParts[] = ':root :where(.' . self::BACKGROUND_IMAGE_CLASS . ') img{max-width:100%}' + . "\n" . ':root :where(.' . self::BACKGROUND_IMAGE_SCALE_CLASS_PREFIX . 'cover,.' . self::BACKGROUND_IMAGE_SCALE_CLASS_PREFIX . 'contain){height:100%}' + . "\n" . ':root :where(.' . self::BACKGROUND_IMAGE_SCALE_CLASS_PREFIX . 'cover) img{width:100%;height:100%;object-fit:cover}' + . "\n" . ':root :where(.' . self::BACKGROUND_IMAGE_SCALE_CLASS_PREFIX . 'contain) img{width:100%;height:100%;object-fit:contain}'; + } + if ( str_contains($serializedBlocks, self::INLINE_LAYOUT_CARRIER_CLASS) ) { + $beforeAuthorCssParts[] = ':where(p.' . self::INLINE_LAYOUT_CARRIER_CLASS . '){display:contents;margin:0!important;padding:0!important;border:0!important}'; + } + if ( str_contains($serializedBlocks, self::CSS_OWNED_LAYOUT_CLASS) ) { + // Gutenberg inserts two editor-only InnerBlocks wrappers between a + // core Group and its children. Keep authored grid/flex children as + // direct layout items, matching the saved frontend markup. + $beforeAuthorCssParts[] = ':root :where(.' . self::CSS_OWNED_LAYOUT_CLASS . ')>.block-editor-inner-blocks,' + . ':root :where(.' . self::CSS_OWNED_LAYOUT_CLASS . ')>.block-editor-inner-blocks>.block-editor-block-list__layout{display:contents}'; + } + if ( str_contains($serializedBlocks, self::CSS_OWNED_FLOW_CLASS) ) { + $beforeAuthorCssParts[] = ':root :where(.' . self::CSS_OWNED_FLOW_CLASS . '>p){margin-top:0;margin-bottom:0}'; + } + if ( str_contains($serializedBlocks, ButtonLinkDispatcher::POSITIONED_FRAGMENT_LINK_CARRIER_CLASS) ) { + // Positioned fragment links retain their source anchor and selectors; + // their valid paragraph host must not create a line box in document flow. + $beforeAuthorCssParts[] = ':where(.' . ButtonLinkDispatcher::POSITIONED_FRAGMENT_LINK_CARRIER_CLASS . '){display:contents!important}'; + } + if ( str_contains($serializedBlocks, self::EMPTY_FLEX_ITEM_CLASS) ) { + $beforeAuthorCssParts[] = ':where(.' . self::EMPTY_FLEX_ITEM_CLASS . '){flex:0 0 0!important;width:0!important;min-width:0!important;margin-left:0!important;margin-right:0!important}'; + } + if ( str_contains($serializedBlocks, self::CSS_OWNED_FLOW_CLASS) ) { + // Core flow spacing is not part of a source grid or flex contract. + // This precedes author CSS so source child margins remain authoritative. + $beforeAuthorCssParts[] = ':root :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. + $beforeAuthorCssParts[] = ':root :where(.' . self::CSS_OWNED_GRID_CLASS . ')>*{margin-block-start:0;margin-block-end:0}'; + } + if ( str_contains($serializedBlocks, self::CSS_OWNED_INLINE_FLOW_CLASS) ) { + // Block delimiters may acquire whitespace when Gutenberg saves the + // post. Flex owns the source's atomic inline flow without counting + // those text nodes as width; later responsive display rules still win. + $beforeAuthorCssParts[] = ':where(.' . self::CSS_OWNED_INLINE_FLOW_CLASS . '){display:flex;flex-wrap:wrap;align-items:baseline;gap:0}' + . "\n" . ':where(.' . self::CSS_OWNED_INLINE_FLOW_CLASS . ')>*{flex:none}'; + } + 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. + $beforeAuthorCssParts[] = ':root :where(.wp-block-group.' . self::CSS_OWNED_LAYOUT_ITEM_CLASS . ')>*{margin-block-start:0;margin-block-end:0}'; + } + if ( str_contains($serializedBlocks, self::PROPAGATED_LINK_COLOR_CARRIER_CLASS) ) { + // The source painted this text; the anchor around it only exists + // because a content-wrapping link was pushed into the block. It + // follows the author cascade so a later authored rule can still + // repaint the link deliberately. + $afterAuthorCssParts[] = ':root :where(.' . self::PROPAGATED_LINK_COLOR_CARRIER_CLASS . ')>a{color:inherit}'; + } + foreach ( $this->navigationStyleProjector->navigationLinkTextColorRules($serializedBlocks) as $navigationLinkTextColorRule ) { + $afterAuthorCssParts[] = $navigationLinkTextColorRule; + } + foreach ( $this->generatedSupportStyles()->navigationInheritedPresentationRules() as $navigationInheritedRule ) { + $afterAuthorCssParts[] = $navigationInheritedRule; + } + foreach ( $this->navigationStyleProjector->navigationLinkIconRules($serializedBlocks) as $navigationLinkIconRule ) { + $afterAuthorCssParts[] = $navigationLinkIconRule; + } + if ( str_contains($serializedBlocks, 'wp:social-link') ) { + // core/social-links paints its own icon for every service. The source + // cluster painted icon-font glyphs through pseudo-elements on the very + // items core now owns, so both icons would render on each link. + $afterAuthorCssParts[] = ':root .wp-block-social-links .wp-social-link::before,' + . ':root .wp-block-social-links .wp-social-link::after,' + . ':root .wp-block-social-links .wp-social-link>a::before,' + . ':root .wp-block-social-links .wp-social-link>a::after{content:none}'; + // The source cluster was an inline box, so its container's text + // alignment placed it. core's list is a full-width flex row, which + // packs the items at the start instead. An inline flex row resolves + // through that same alignment, for centered and start-aligned + // containers alike, while an explicit justification still wins. + $afterAuthorCssParts[] = ':root ul.wp-block-social-links:not([class*="is-content-justification-"]){display:inline-flex}'; + } + array_push($afterAuthorCssParts, ...$this->generatedSupportStyles()->conditionalAfterAuthorCss($serializedBlocks)); + if ( str_contains($serializedBlocks, 'blocks-engine-list-navigation') ) { + $beforeAuthorCssParts[] = '.wp-block-navigation.blocks-engine-list-navigation .wp-block-navigation-item.wp-block-navigation-link{display:list-item;font:inherit}' + . "\n" . '.wp-block-navigation.blocks-engine-list-navigation .wp-block-navigation-item__content{display:inline}' + . "\n" . '.wp-block-navigation.blocks-engine-list-navigation .wp-block-navigation__container{display:flex;flex-direction:row;flex-wrap:wrap;list-style:none}'; + } + $nativeSearchTriggerCss = $this->generatedSupportStyles()->beforeAuthorCss(); + if ( '' !== $nativeSearchTriggerCss ) { + $beforeAuthorCssParts[] = $nativeSearchTriggerCss; + } + if ( '' !== trim($authorCss) ) { + $authorCssParts[] = $authorCss; + $adminBarAccommodation = (new AdminBarAccommodation())->supportCss($authorCss); + if ( '' !== $adminBarAccommodation ) { + $afterAuthorCssParts[] = $adminBarAccommodation; + } + } + if ( str_contains($serializedBlocks, 'blocks-engine-list-navigation') ) { + // Keep only source-responsive navigation hosts visible. Ordinary + // link rows retain authored mobile display rules without core's + // overlay control replacing them. + if ( str_contains($serializedBlocks, 'blocks-engine-native-responsive-navigation') ) { + $afterAuthorCssParts[] = '.wp-block-navigation.blocks-engine-list-navigation.blocks-engine-native-responsive-navigation{display:flex!important}'; + } + if ( str_contains($serializedBlocks, 'blocks-engine-projected-dialog-navigation') ) { + $mobileOverlayBackground = $this->navigationStyleProjector->sourceMobileNavigationOverlayBackground(); + $fallbackTextColor = ''; + if ( '' === $mobileOverlayBackground ) { + $mobileOverlayBackground = '#fff'; + $fallbackTextColor = 'color:#111!important;'; + } + $projectedOpenMenu = '.wp-block-navigation.blocks-engine-projected-dialog-navigation .wp-block-navigation__responsive-container.is-menu-open'; + $afterAuthorCssParts[] = $projectedOpenMenu . '{background:' . $mobileOverlayBackground . '!important;' . $fallbackTextColor . 'position:fixed!important;inset:0!important;padding:clamp(4rem,12vh,7rem) clamp(1.5rem,6vw,4rem) 2rem!important;overflow-y:auto!important;z-index:99998!important}' + . "\n" . $projectedOpenMenu . ' .wp-block-navigation__responsive-container-content{align-items:flex-start!important;justify-content:flex-start!important;gap:1rem!important;width:100%!important}' + . "\n" . $projectedOpenMenu . ' .wp-block-navigation__container{align-items:flex-start!important;gap:.75rem!important;width:100%!important}' + . "\n" . $projectedOpenMenu . ' .wp-block-navigation-item__content{' . $fallbackTextColor . 'font-size:clamp(1.125rem,4vw,1.5rem)!important;line-height:1.4!important;padding:.5rem 0!important}' + . "\n" . $projectedOpenMenu . ' .wp-block-navigation__responsive-container-close{background:#fff!important;color:#111!important;position:fixed!important;top:1rem!important;right:1rem!important;padding:.75rem!important;z-index:1!important}' + . "\n" . 'body.admin-bar ' . $projectedOpenMenu . '{top:var(--wp-admin--admin-bar--height,32px)!important}' + . "\n" . 'body.admin-bar ' . $projectedOpenMenu . ' .wp-block-navigation__responsive-container-close{top:calc(1rem + var(--wp-admin--admin-bar--height,32px))!important}'; + } + // Size a carried menu to its content when it sits inside a brand + // carrier. The carrier renders