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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions php-transformer/docs/form-presentation-graph.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Form Presentation Graph

`generic/computed-form-presentation/v2` is an optional `generic/forms/v1` payload member. It carries bounded source-CSS facts for controls, associated labels, and identified inline SVG descendants. It is not a provider recipe and never assigns visual semantics such as "globe" or "chevron".

```php
array{
schema: 'generic/computed-form-presentation/v2',
basis: 'source_css_cascade',
controls: list<array{index: int, control?: Role, label?: Role, required_marker?: Role}>,
visual_parts: list<array{
id: 'control-{control order}-svg-{descendant order}', index: int,
kind: 'inline_svg', source_selector: string, markup: safe-svg,
intrinsic_size?: array{width: positive-int, height: positive-int},
source_css: array{state: 'known', styles: array<string,string>, provenance: list<Provenance>}|array{state: 'unknown'}
}>,
variants: list<array{index: int, role: 'control'|'label'|'required_marker'|'visual_part', part_id?: string, condition: Condition, style_patch: array<string,string>, precedence: array<string,array>, provenance: list<Provenance>}>,
truncated: bool, limits: array{controls: 128, rules_per_role: 32}, diagnostics: list<string>
}
```

`visual_parts` contains only drawable SVG descendants that pass `SourceDom::isSafeInlineSvgMarkup()`. `source_selector` identifies the source descendant and `index` links it to the source control order. `source_css.state: 'unknown'` means no unconditional source-CSS facts were matched. Responsive facts can still be present in `variants`; consumers evaluate those conditions rather than treating an unknown base as missing presentation. Consumers must not infer a role or layout from SVG shape or order.

`required_marker` is emitted only for the existing explicit required marker identity: an `aria-hidden="true"` span containing one to four asterisks on a required control's associated label. It records the marker's own matched CSS and variants; when it inherits the label unchanged, it safely has empty `styles` and `provenance` rather than invented source facts. Existing v1 graphs remain valid. Graphs without visual parts or an explicit required marker retain the v1 envelope; graphs with either emit v2. This contract describes source appearance, not interaction-state semantics or provider destinations.

The unshipped v2 envelope also includes `visual_groups`. Each group records the source selector of the SVG parts' lowest common ancestor, a stable `id`, ordered `part_ids`, and `source_css` using the same base-fact shape as visual parts. Group responsive variants use `role: visual_group` and `group_id` rather than a control index. Consumers preserve those authored container facts independently of each SVG's intrinsic dimensions and keep provider visibility state outside that layout container.
5 changes: 5 additions & 0 deletions php-transformer/src/ArtifactCompiler/ArtifactCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -2666,6 +2666,11 @@ private function stylesheetAssetsForSource(string $html, string $sourcePath, arr
if ( is_array($file) && ! isset($seenPaths[$file['path']]) ) {
$assets[] = array( 'path' => $file['path'], 'source_path' => $file['source_path'] ?? $file['path'], 'content' => $file['content'], 'source_hash' => (string) ($file['provenance']['hash'] ?? hash('sha256', $file['content']) ), 'media' => (string) ($file['media'] ?? ''), 'type' => (string) ($file['type'] ?? '') );
$seenPaths[$file['path']] = true;
} elseif ( '' !== ($content = trim(html_entity_decode($tagRecord['content'], ENT_QUOTES | ENT_HTML5, 'UTF-8'))) ) {
// Generated inline-style files can be omitted at the artifact
// file limit. Their source HTML was accepted independently,
// so retain the authored stylesheet for source analysis.
$assets[] = array( 'path' => 'inline-style-' . $inlineIndex . '.css', 'source_path' => 'inline-style', 'content' => $content, 'source_hash' => hash('sha256', $content), 'media' => $this->htmlAttribute($attributes, 'media'), 'type' => $this->htmlAttribute($attributes, 'type') );
}
continue;
}
Expand Down
11 changes: 11 additions & 0 deletions php-transformer/src/Css/CssAnalysisLimits.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);

namespace Automattic\BlocksEngine\PhpTransformer\Css;

/** Shared bounds for source-preserving CSS analysis consumers. */
final class CssAnalysisLimits
{
// Allows a complete author stylesheet while retaining a bounded parser input.
public const MAX_STYLESHEET_BYTES = 4194304;
}
25 changes: 24 additions & 1 deletion php-transformer/src/Css/CssRuleAnalyzer.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ private function analyzeStylesheet(string $css, string $path, string $hash, ?arr
for ( $offset = 0, $length = strlen($css); $offset < $length; ) {
$boundary = $this->nextRuleBoundary($css, $offset);
if ( null === $boundary ) {
if ( '' !== trim(substr($css, $offset)) ) {
if ( $this->hasNonTrivia($css, $offset) ) {
$result['diagnostics'][] = 'malformed_stylesheet:' . $path;
}
return;
Expand Down Expand Up @@ -249,6 +249,29 @@ private function nextRuleBoundary(string $css, int $offset): ?int
return null;
}

/**
* A stylesheet may legally end with whitespace and comments, including a
* source-map comment. Preserve malformed-input diagnostics for every other
* incomplete trailing token.
*/
private function hasNonTrivia(string $css, int $offset): bool
{
$state = CssSyntaxScanner::state();
for ($length = strlen($css); $offset < $length; ) {
$insideComment = $state['comment'];
$startsComment = ! $insideComment && '' === $state['quote'] && '/*' === substr($css, $offset, 2);
$next = CssSyntaxScanner::consume($css, $offset, $state);
if ( null === $next ) {
return true;
}
if (! $insideComment && ! $startsComment && ! CssSyntaxScanner::isCssWhitespace($css[$offset])) {
return true;
}
$offset = $next;
}
return ! CssSyntaxScanner::isComplete($state);
}

/**
* CSS comments disappear from selectors, but adjacent identifier-like tokens
* need a separator to avoid changing a descendant selector into one token.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ public function isPositiveCssLength(string $value): bool
public function isVisibleEmptyVisualPaint(string $value): bool
{
$value = strtolower(trim($value));
if ( '' === $value || 'none' === $value || 'transparent' === $value || preg_match('/^rgba?\([^)]*,\s*0(?:\.0+)?\s*\)$/', $value) ) {
if ( '' === $value || 'none' === $value || 'transparent' === $value || preg_match('/^0(?:px|%)?(?:\s+0(?:px|%)?)?$/', $value) || preg_match('/^rgba?\([^)]*,\s*0(?:\.0+)?\s*\)$/', $value) ) {
return false;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,8 @@ public function control(DOMElement $control): array

if ( $control->hasAttribute('required') || 'true' === strtolower(trim(SourceDom::attr($control, 'aria-required'))) ) {
$metadata['required'] = true;
if ( $labelElement instanceof DOMElement ) {
foreach ( $labelElement->getElementsByTagName('span') as $marker ) {
$text = trim($marker->textContent ?? '');
if ( 'true' === strtolower(SourceDom::attr($marker, 'aria-hidden')) && preg_match('/^\*{1,4}$/D', $text) ) {
$metadata['required_text'] = $text;
break;
}
}
if ( ($marker = $this->requiredMarker($control)) instanceof DOMElement ) {
$metadata['required_text'] = trim($marker->textContent ?? '');
}
}
foreach ( array( 'disabled', 'readonly', 'checked', 'multiple' ) as $attribute ) {
Expand Down Expand Up @@ -192,6 +186,25 @@ public function associatedLabel(DOMElement $control): ?DOMElement
return null;
}

/** The explicit decorative required marker also supplies provider presentation identity. */
public function requiredMarker(DOMElement $control): ?DOMElement
{
if ( ! $control->hasAttribute('required') && 'true' !== strtolower(trim(SourceDom::attr($control, 'aria-required'))) ) {
return null;
}
$label = $this->labelElement($control);
if ( ! $label instanceof DOMElement ) {
return null;
}
foreach ( $label->getElementsByTagName('span') as $marker ) {
$text = trim($marker->textContent ?? '');
if ( 'true' === strtolower(SourceDom::attr($marker, 'aria-hidden')) && preg_match('/^\*{1,4}$/D', $text) ) {
return $marker;
}
}
return null;
}

private function labelElement(DOMElement $control): ?DOMElement
{
$label = $this->associatedLabel($control);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ public function build(DOMElement $element, ?array $readableFormBlock, ?array $bi
$controlTopology = (new FormControlTopologyBuilder())->build($element);
$layoutGraph = (new FormLayoutGraphBuilder())->build($element, $this->context->stylesheetAssets(), $this->context->formLayoutCss());
$presentationGraph = (new FormPresentationGraphBuilder(
fn (DOMElement $control, string $value): string => $this->context->resolvePresentationValue($control, $value)
fn (DOMElement $control, string $value): string => $this->context->resolvePresentationValue($control, $value),
fn (DOMElement $element): string => $this->context->sanitizeInlineSvgMarkup($element),
fn (DOMElement $control): ?DOMElement => $this->metadataBuilder->requiredMarker($control)
))->build($element, $this->context->stylesheetAssets(), $this->context->formLayoutCss());
$boundedHtml = $this->context->boundedFallbackHtml($element);
$replacesRuntimeIsland = null !== $bindingBlock;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ final class FormFallbackFindingContext
* @param Closure(DOMElement): array<string, mixed> $classifyFallbackSubtree
* @param Closure(array<string, mixed>, string, array<int, string>): array<string, mixed> $blockBinding
* @param (Closure(DOMElement, string): string)|null $resolvePresentationValue
* @param (Closure(DOMElement): string)|null $sanitizeInlineSvgMarkup
*/
public function __construct(
private readonly HtmlTransformerSession $session,
Expand All @@ -26,7 +27,8 @@ public function __construct(
private readonly Closure $sourceContext,
private readonly Closure $classifyFallbackSubtree,
private readonly Closure $blockBinding,
private readonly ?Closure $resolvePresentationValue = null
private readonly ?Closure $resolvePresentationValue = null,
private readonly ?Closure $sanitizeInlineSvgMarkup = null
) {
}

Expand Down Expand Up @@ -80,6 +82,11 @@ public function resolvePresentationValue(DOMElement $element, string $value): st
return null !== $this->resolvePresentationValue ? ($this->resolvePresentationValue)($element, $value) : $value;
}

public function sanitizeInlineSvgMarkup(DOMElement $element): string
{
return null !== $this->sanitizeInlineSvgMarkup ? ($this->sanitizeInlineSvgMarkup)($element) : '';
}

/** @param array<string, mixed> $finding @return array<string, mixed> */
public function buildFallbackDiagnostic(array $finding): array
{
Expand Down
7 changes: 5 additions & 2 deletions php-transformer/src/HtmlToBlocks/HtmlCompilation.php
Original file line number Diff line number Diff line change
Expand Up @@ -617,7 +617,8 @@ function (DOMElement $element, array &$fallbacks, bool $captureUnsupported): arr
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 (DOMElement $element, string $value): string => $this->styleResolver->resolveCssVariablesInValue($value, $element)
fn (DOMElement $element, string $value): string => $this->styleResolver->resolveCssVariablesInValue($value, $element),
fn (DOMElement $element): string => $this->svgMaterializer->restoreSvgCasing($this->sanitizeInlineSvgMarkup($element))
),
$this->formControlMetadataBuilder,
$this->formSuccessPanelMetadataBuilder,
Expand Down Expand Up @@ -1928,7 +1929,9 @@ private function materializeAuthorStylesheet(string $html, string $staticCss, bo
// 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}';
. ':root :where(.' . self::CSS_OWNED_LAYOUT_CLASS . ')>.block-editor-inner-blocks>.block-editor-block-list__layout{display:contents}'
// Empty Group placeholders have an additional unadorned editor wrapper.
. ':root .editor-styles-wrapper :where(.' . self::CSS_OWNED_LAYOUT_CLASS . ')>div:not([class]):not([id]):not([style]):has(>[data-block].wp-block-group__placeholder){display:contents}';
}
$layoutShellBlockName = $this->generatedBlocks()->blockName('layout-shell');
if ( str_contains($serializedBlocks, '<!-- wp:' . $layoutShellBlockName) ) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,38 @@ public function build(DOMElement $form): array
);
}

/**
* Return each control's nearest-to-farthest wrapper ancestors that contain no
* other form controls. Labels and other non-controls intentionally do not
* make a wrapper shared.
*
* @return array<int, list<DOMElement>>
*/
public function exclusiveWrapperAncestors(DOMElement $form): array
{
$result = array();
$controls = $this->controls($form);
$owners = array();
foreach ( $controls as $control ) {
$depth = 0;
for ( $ancestor = $control->parentNode; $ancestor instanceof DOMElement && ! $ancestor->isSameNode($form) && $depth < self::MAX_DEPTH; $ancestor = $ancestor->parentNode, ++$depth ) {
$path = $ancestor->getNodePath();
$owners[$path] = ($owners[$path] ?? 0) + 1;
}
}
foreach ( $controls as $index => $control ) {
$ancestors = array();
$depth = 0;
for ( $ancestor = $control->parentNode; $ancestor instanceof DOMElement && ! $ancestor->isSameNode($form) && $depth < self::MAX_DEPTH; $ancestor = $ancestor->parentNode, ++$depth ) {
if ( 1 === ($owners[$ancestor->getNodePath()] ?? 0) ) {
$ancestors[] = $ancestor;
}
}
$result[$index] = $ancestors;
}
return $result;
}

/**
* @param array<string, int> $controlIndexes
* @param array<string, bool> $relevantElements
Expand Down
Loading
Loading