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
16 changes: 15 additions & 1 deletion php-transformer/src/HtmlToBlocks/HtmlTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -1090,7 +1090,15 @@ private function rewriteAuthorSelectorPrelude(string $prelude, bool $controlWrap
}
$matches = $this->matchingAuthorSourceElements($parsed);
if ( array() === $matches ) {
$rewritten[] = $selector;
// A type selector (e.g. `.page-header p`) that matches no source
// element must still be projected through its source-tag marker
// rather than emitted bare. Otherwise a `<div>` later collapsed to a
// `<p>` (an eyebrow `<div class="label">`) would be newly captured by
// the dormant `.page-header p` rule and lose its own type scale.
// Rewriting to `:where(.source-p-marker)` — carried only by elements
// that were `<p>` in the source — makes the rule match exactly what
// the author intended and nothing that was structurally promoted.
$rewritten[] = $this->rewriteSourceTagTypes($selector, $parsed);
continue;
}
if ( $this->isRootChildSelector($parsed) ) {
Expand Down Expand Up @@ -3754,6 +3762,12 @@ private function visualTextWrapperBlockFromElement(DOMElement $element): ?array
// decoration, which the wrapper class still applies to the paragraph. Real
// flex containers hold child elements and are already excluded above by the
// `childElementCount === 0` guard (e.g. `.tier-price` wrapping a `<span>`).
//
// Descendant paragraph rules the source used a non-`p` tag to escape (e.g.
// `.page-header p { font-size: ... }` styling body copy while an eyebrow
// authored as `<div class="label">` avoided it) do not capture the collapsed
// paragraph: author `p` type selectors are projected through the source-`p`
// tag marker, which only elements that were `<p>` in the source carry.
if ( 0 === $this->childElementCount($element) && ! $this->hasBoxChromeWrapperStyling($element) ) {
return $this->createBlock(
'core/paragraph',
Expand Down
38 changes: 38 additions & 0 deletions php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ public function match(DOMElement $element, PatternContext $context): ?array
return null;
}

// A row of button-styled links (e.g. `<div class="stream-links"><a
// class="stream-btn">…</a>…</div>`) is a call-to-action button group, not
// site navigation. It matched here only because a container token like
// `links` looks navigational, but its anchors carry button signals and
// belong to the buttons pattern, which preserves their pill geometry and
// styling. Defer so navigation does not flatten them into menu items.
if ( 'nav' !== strtolower($element->tagName) && ! $this->hasDirectListNavigationSignal($element) && $this->hasButtonStyledLinkChildren($element) ) {
return null;
}

if ( $this->hasDirectBrandingAnchorBesideListNavigation($element, $innerHtml) ) {
return null;
}
Expand Down Expand Up @@ -846,6 +856,34 @@ private function collectAnchorsExcluding(DOMElement $element, array &$anchors, a
}
}

/**
* Whether the container's direct link children are button-styled call-to-
* action anchors rather than navigation links. Requires every direct anchor
* to carry a button signal so a genuine nav menu with one incidental
* button-classed link is not misclassified.
*/
private function hasButtonStyledLinkChildren(DOMElement $element): bool
{
$classifier = new ButtonSignalClassifier();
$anchors = array();
foreach ( $element->childNodes as $child ) {
if ( $child instanceof DOMElement && 'a' === strtolower($child->tagName) ) {
$anchors[] = $child;
}
}
if ( 2 > count($anchors) ) {
return false;
}

foreach ( $anchors as $anchor ) {
if ( ! $classifier->hasTransformSignal($anchor) ) {
return false;
}
}

return true;
}

private function hasNavigationSignal(DOMElement $element): bool
{
if ( 'navigation' === strtolower($element->hasAttribute('role') ? $element->getAttribute('role') : '') ) {
Expand Down
10 changes: 10 additions & 0 deletions php-transformer/tests/contract/run.php
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,16 @@ public function match(DOMElement $element, PatternContext $context): ?array
$assert('About' === ($navigationBlock['innerBlocks'][0]['attrs']['label'] ?? null), 'navigation conversion still preserves link labels');
$assert('/about' === ($navigationBlock['innerBlocks'][0]['attrs']['url'] ?? null), 'navigation conversion still preserves link URLs');

// A row of button-styled links whose container merely carries a `links` token is
// a call-to-action button group, not site navigation. It must convert to
// core/buttons (preserving pill geometry) instead of being flattened into a
// core/navigation menu of half-height text links.
$ctaLinkRowResult = ( new HtmlTransformer() )->transform('<div class="stream-links"><a class="stream-btn" href="#">Spotify</a><a class="stream-btn" href="#">Bandcamp</a><a class="stream-btn" href="#">Apple Music</a></div>')->toArray();
$ctaSerialized = (string) ($ctaLinkRowResult['serialized_blocks'] ?? '');
$assert(str_contains($ctaSerialized, '<!-- wp:buttons'), 'button-styled link row converts to core/buttons instead of navigation');
$assert(! str_contains($ctaSerialized, '<!-- wp:navigation'), 'button-styled link row is not misclassified as core/navigation');
$assert(str_contains($ctaSerialized, 'stream-links'), 'the converted button group preserves the container class');

$accordionResult = ( new HtmlTransformer() )->transform('<section class="faq"><div class="faq-item active"><button class="faq-question" aria-expanded="true" aria-controls="answer-a">What is covered?</button><div id="answer-a" class="faq-answer"><p>Assessment and treatment planning.</p></div></div><div class="faq-item"><button class="faq-question" aria-expanded="false" aria-controls="answer-b">How long is a visit?</button><div id="answer-b" class="faq-answer"><p>Most visits take 45 minutes.</p></div></div></section>')->toArray();
$accordionBlock = $accordionResult['blocks'][0] ?? array();
$accordionItems = $accordionBlock['innerBlocks'] ?? array();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,26 @@
$assert(1 === count($multiPageAuthorAssets), 'identical generated author stylesheets are emitted once across HTML routes');
$assert('blocks-engine/wordpress-site-plan/v2' === ($multiPage['source_reports']['wordpress_site_plan']['schema'] ?? null), 'deduplicated multi-route assets produce a canonical WordPress site plan');

// Collapsed-paragraph cascade isolation via the source-`p` tag marker. A shared
// stylesheet carries a descendant-`p` body-copy rule (`.page-header p`) and an
// eyebrow (`.label`) that collapses to a paragraph. The projected `.page-header
// p` rule must target the source-`p` marker — carried only by elements that were
// `<p>` in the source — so it styles the real intro paragraph but never captures
// the collapsed eyebrow, which keeps its own class-owned type scale. No bare
// `.page-header p` may survive to capture the promoted paragraph.
$collapse = ( new ArtifactCompiler() )->compile(array(
'entrypoint' => 'index.html',
'files' => array(
array( 'path' => 'index.html', 'kind' => 'html', 'content' => '<link rel="stylesheet" href="shared.css"><header class="page-header"><div class="label">Home Eyebrow</div><h1>Home</h1><p>Home intro copy.</p></header>' ),
array( 'path' => 'about.html', 'kind' => 'html', 'content' => '<link rel="stylesheet" href="shared.css"><header class="page-header"><div class="label">About Eyebrow</div><h1>About</h1><p>About intro copy.</p></header>' ),
array( 'path' => 'shared.css', 'kind' => 'css', 'content' => '*,*::before,*::after{margin:0;padding:0}.label{display:inline-flex;gap:.5rem;font-size:.68rem;letter-spacing:.2em;text-transform:uppercase}.page-header p{font-size:1.2rem;font-style:italic}' ),
),
) )->toArray();
$collapseCss = implode("\n", array_map(static fn (array $asset): string => (string) ($asset['content'] ?? ''), array_values(array_filter($collapse['assets'] ?? array(), static fn (array $asset): bool => 'css' === ($asset['kind'] ?? '') && str_contains((string) ($asset['content'] ?? ''), 'page-header')))));
$assert(str_contains($collapseCss, 'font-size:.68rem'), 'collapsed eyebrow keeps its own class-owned font-size rule');
$assert(! preg_match('/(?:^|[\s>~+])\.page-header p\s*\{/', $collapseCss), 'no bare .page-header p rule survives to capture the collapsed eyebrow');
$assert(preg_match('/\.page-header\s+:where\(\.blocks-engine-source-p-[a-f0-9]+-\d+\)/', $collapseCss) === 1, 'descendant .page-header p is projected through the source-p tag marker so it matches only real source paragraphs');

if ( $failures > 0 ) {
exit(1);
}
Expand Down
Loading