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
2 changes: 2 additions & 0 deletions php-transformer/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"static-parity": "php tools/static-parity/run.php",
"live-wp-parity": "php tools/live-wp-parity/run.php",
"visual:image-custom-host-promotion": "node tools/visual-parity/tests/image-custom-host-promotion.mjs",
"visual:empty-destination-anchor": "node tools/visual-parity/tests/empty-destination-anchor.mjs",
"benchmark:selector-cache": "php tools/benchmarks/css-selector-match-cache.php",
"benchmark:staged-page-compilation": "php tools/benchmarks/staged-page-compilation-production.php",
"corpus-diagnostics": "php tools/corpus-diagnostics/run.php",
Expand Down Expand Up @@ -377,6 +378,7 @@
"php tests/unit/runtime-island-analyzer.php",
"php tests/unit/commerce-structure-recognizer.php",
"php tests/unit/button-link-dispatcher.php",
"php tests/unit/empty-destination-anchor.php",
"php tests/unit/button-element-converter.php",
"php tests/unit/block-content-button-not-richtext.php",
"php tests/unit/image-carrier-hidden-label.php",
Expand Down
10 changes: 10 additions & 0 deletions php-transformer/src/HtmlToBlocks/Elements/ButtonLinkDispatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,16 @@ public function convertAnchor(DOMElement $element, array &$fallbacks): ?array
return $this->paragraphHost($element);
}

// A childless whitespace-only link still owns its destination and may
// own a CSS hitbox. Use the same native RichText host as other links;
// neither a duplicate sibling destination nor absent text proves that
// it is safe to omit. Keep media-bearing anchors on their own lowering.
if ( 0 === SourceDom::childElementCount($element)
&& '' === trim($element->textContent ?? '')
&& '' !== $this->context->safeLinkUrl(SourceDom::attr($element, 'href')) ) {
return $this->paragraphHost($element);
}

// A text-less anchor is not an empty anchor: icons, one image, a whole
// image feed, a media carrier — whatever it wraps is content, and the
// link-wrapper group is the lowering that keeps it while propagating the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@
{ "path": "blocks.1", "name": "core/image", "attrs": { "url": "assets/profile.jpg", "alt": "Profile", "href": "/profile", "linkDestination": "custom" } },
{ "path": "blocks.2", "name": "core/group", "attrs": { "className": "strip" } },
{ "path": "blocks.2.innerBlocks.0", "name": "core/image", "attrs": { "url": "assets/three.jpg", "alt": "Three", "href": "/strip", "linkDestination": "custom" } },
{ "path": "blocks.2.innerBlocks.1", "name": "core/image", "attrs": { "url": "assets/four.jpg", "alt": "Four", "href": "/strip", "linkDestination": "custom" } }
{ "path": "blocks.2.innerBlocks.1", "name": "core/image", "attrs": { "url": "assets/four.jpg", "alt": "Four", "href": "/strip", "linkDestination": "custom" } },
{ "path": "blocks.3", "name": "core/paragraph" }
],
"expect": [
{ "path": "status", "assert": "equals", "value": "success" },
{ "path": "blocks", "assert": "count", "count": 3 },
{ "path": "blocks", "assert": "count", "count": 4 },

{ "path": "blocks.0.blockName", "assert": "equals", "value": "core/group" },
{ "path": "blocks.0.innerBlocks", "assert": "count", "count": 2 },
Expand All @@ -52,11 +53,9 @@
{ "path": "serialized_blocks", "assert": "contains", "value": "<a href=\"/strip\"><img src=\"assets/three.jpg\"" },
{ "path": "serialized_blocks", "assert": "contains", "value": "<a href=\"/strip\"><img src=\"assets/four.jpg\"" },

{ "path": "fallbacks", "assert": "count", "count": 1 },
{ "path": "fallbacks.0.diagnostic_code", "assert": "equals", "value": "html_unsupported_element" },
{ "path": "fallbacks.0.tag", "assert": "equals", "value": "a" },
{ "path": "fallbacks.0.selector", "assert": "contains", "value": "a:nth-of-type(4)" },
{ "path": "fallbacks.0.loss_class", "assert": "equals", "value": "unsupported_element_loss" },
{ "path": "coverage.0.fallback_count", "assert": "equals", "value": 1 }
{ "path": "blocks.3.blockName", "assert": "equals", "value": "core/paragraph" },
{ "path": "serialized_blocks", "assert": "contains", "value": "href=\"/nothing\"" },
{ "path": "fallbacks", "assert": "count", "count": 0 },
{ "path": "coverage.0.fallback_count", "assert": "equals", "value": 0 }
]
}
4 changes: 2 additions & 2 deletions php-transformer/tests/unit/button-link-dispatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,8 @@ static function (DOMElement $e, string $kind) use (&$islandRecorded): void {
// A plain button that matches no pattern yields nothing.
$assert(null === $makeDispatcher()->convertButton($elementFrom('<button>go</button>')), 'unmatched-button-yields-nothing');

// An empty anchor with no accessible name drops.
$assert(null === $makeDispatcher()->convertAnchor($elementFrom('<a href="/x"></a>'), $fallbacks), 'empty-anchor-drops');
// An empty safe link retains its destination on a native RichText host.
$assert('core/paragraph' === ($makeDispatcher()->convertAnchor($elementFrom('<a href="/x"></a>'), $fallbacks)['blockName'] ?? ''), 'empty-anchor-retains-destination');

// Untitled inline SVG is visual content. Convert children instead of dropping.
$svgChildren = $makeDispatcher(array(
Expand Down
44 changes: 44 additions & 0 deletions php-transformer/tests/unit/empty-destination-anchor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);

use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\HtmlTransformer;
use Automattic\BlocksEngine\PhpTransformer\WordPress\BlockValidityValidator;

require dirname(__DIR__, 2) . '/vendor/autoload.php';

$assertions = 0;
$assert = static function (bool $condition, string $label) use (&$assertions): void {
++$assertions;
if (! $condition) throw new RuntimeException($label);
};
$cases = array(
'redundant whitespace sibling' => '<div class="media"><a href="https://example.com/plant"><img src="plant.jpg" alt="Pencil Plant"></a><a class="blank" href="https://example.com/plant">\n \n</a></div>',
'accessible overlay' => '<div class="notice"><span id="notice-text">Tickets available</span><a class="overlay" href="https://example.com/tickets" aria-labelledby="notice-text"></a></div>',
'positive geometry' => '<a class="hitbox" href="https://example.com/plant"></a>',
'unique destination' => '<a id="unique-link" data-purpose="destination" class="blank" href="https://example.com/unique"> </a>',
);
$cases['redundant whitespace sibling'] = str_replace('\\n', "\n", $cases['redundant whitespace sibling']);
$css = '<style>.media a,.blank{display:block;line-height:0;width:100%}.notice{position:relative;width:300px;height:40px}.overlay{position:absolute;inset:0}.hitbox{display:block;width:120px;height:48px}</style>';
foreach ($cases as $label => $source) {
$result = (new HtmlTransformer())->transform($css . '<main>' . $source . '</main>')->toArray();
$markup = $result['serialized_blocks'];
$assert(array() === $result['fallbacks'], "$label has no unsupported fallback");
$assert(! str_contains($markup, '<!-- wp:html'), "$label stays native");
$assert(str_contains($markup, '<a ') && str_contains($markup, 'href="https://example.com/'), "$label retains its destination");
$assert('pass' === (new BlockValidityValidator())->validateBlocks($result['blocks'])['status'], "$label has valid native save grammar");
if ('redundant whitespace sibling' === $label) {
$assert(str_contains($markup, '<!-- wp:image') && str_contains($markup, 'Pencil Plant'), 'linked image remains natively editable with its alternative text');
$assert(2 === substr_count($markup, 'href="https://example.com/plant"'), 'image and empty sibling both retain their shared destination');
}
if ('accessible overlay' === $label) {
$assert(str_contains($markup, 'aria-labelledby="notice-text"') && str_contains($markup, 'id="notice-text"'), 'overlay accessible-name relationship survives');
}
if ('unique destination' === $label) {
$assert(1 === substr_count($markup, 'id="unique-link"') && str_contains($markup, 'data-purpose="destination"'), 'empty link identity remains on one native anchor');
}
}
$unsafe = (new HtmlTransformer())->transform('<main><a href="javascript:alert(1)"> </a></main>')->toArray();
$assert(! str_contains($unsafe['serialized_blocks'], 'href="javascript:'), 'unsafe empty destination does not become a native link');
$unsupported = (new HtmlTransformer())->transform('<main><applet>Unsupported content</applet></main>')->toArray();
$assert('html_unsupported_element' === ($unsupported['fallbacks'][0]['diagnostic_code'] ?? ''), 'unrelated unsupported content still reports its loss');
echo "OK: empty destination anchors ({$assertions} assertions)\n";
4 changes: 2 additions & 2 deletions php-transformer/tests/unit/empty-named-fragment-target.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@
'<main><a href="/x"></a><p>After</p></main>'
)->toArray();
$assert(
'html_unsupported_element' === ( $hrefEmpty['fallbacks'][0]['diagnostic_code'] ?? null ),
'14: an empty destination-bearing anchor remains an unsupported element',
array() === $hrefEmpty['fallbacks'] && str_contains($hrefEmpty['serialized_blocks'], 'href="/x"'),
'14: an empty destination-bearing anchor remains a native link rather than a fragment target',
json_encode($hrefEmpty['fallbacks'] ?? array())
);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { chromium } from 'playwright';

const root = new URL('../../..', import.meta.url).pathname;
const image = 'data:image/gif;base64,R0lGODlhAQABAAAAACw=';
const css = '.media{width:80%;}.media a{display:block;width:100%;line-height:0}.media img{width:80px;height:60px}.notice{position:relative;width:300px;height:40px}.overlay{position:absolute;inset:0}.hitbox{display:block;width:120px;height:48px}.ratio{display:block;width:120px;aspect-ratio:1.5;line-height:0}.unique{display:block;line-height:0}';
const fixtures = [
['redundant whitespace sibling', `<div class="media"><a href="https://example.com/plant"><img src="${image}" alt="Pencil Plant"></a><a class="blank" href="https://example.com/plant">\n \n</a></div>`, '.blank', 0],
['accessible overlay', '<div class="notice"><p id="notice-text">Tickets available</p><a class="overlay" aria-labelledby="notice-text" href="https://example.com/tickets"></a></div>', '.overlay', 40],
['positive geometry', '<a class="hitbox" href="https://example.com/plant"></a>', '.hitbox', 48],
['aspect ratio geometry', '<a class="ratio" href="https://example.com/plant">\n </a>', '.ratio', 80],
['unique destination', '<a class="unique" href="https://example.com/unique">\n </a>', '.unique', 0],
];
function transform(source) {
const code = 'require $argv[1] . "/vendor/autoload.php"; echo json_encode((new Automattic\\BlocksEngine\\PhpTransformer\\HtmlToBlocks\\HtmlTransformer())->transform(base64_decode($argv[2]))->toArray());';
return JSON.parse(execFileSync('php', ['-r', code, root, Buffer.from(source).toString('base64')], { encoding: 'utf8' }));
}
const browser = await chromium.launch({ headless: true });
try {
for (const [name, source, selector, height] of fixtures) {
const result = transform(`<style>${css}</style><main>${source}</main>`);
assert.deepEqual(result.fallbacks, [], `${name}: no unsupported finding`);
const generatedCss = result.assets.filter(asset => asset.kind === 'css').map(asset => asset.content ?? '').join('\n');
for (const width of [390, 768, 1440]) {
const page = await browser.newPage({ viewport: { width, height: 900 } });
const probe = async (html, styles) => {
await page.setContent(`<!doctype html><style>body{margin:0}${styles}</style>${html}`);
return page.locator(selector).evaluate(link => {
const box = link.getBoundingClientRect();
const labelledby = link.getAttribute('aria-labelledby');
return { width: box.width, height: box.height, href: link.getAttribute('href'), name: labelledby ? document.getElementById(labelledby)?.textContent : link.getAttribute('aria-label'), hit: box.height > 0 ? document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2)?.closest('a')?.getAttribute('href') : null };
});
};
const before = await probe(`<main>${source}</main>`, css);
assert.equal(before.height, height, `${name}: source hitbox at ${width}px`);
const after = await probe(result.serialized_blocks, generatedCss);
assert.deepEqual(after, before, `${name}: destination, name and hitbox conserved at ${width}px`);
if (name === 'redundant whitespace sibling') {
assert.equal(await page.locator('a:has(img)').getAttribute('href'), 'https://example.com/plant');
assert.equal(await page.locator('img').getAttribute('alt'), 'Pencil Plant');
}
await page.close();
}
}
console.log('OK: empty destination anchor browser regression (5 cases × 3 viewports)');
} finally {
await browser.close();
}
Loading