diff --git a/packages/blocks-engine/src/__tests__/theme-source-assets-parity.test.ts b/packages/blocks-engine/src/__tests__/theme-source-assets-parity.test.ts
index 2c854dae..4fdffcc3 100644
--- a/packages/blocks-engine/src/__tests__/theme-source-assets-parity.test.ts
+++ b/packages/blocks-engine/src/__tests__/theme-source-assets-parity.test.ts
@@ -8,6 +8,7 @@ import * as theme from '../theme/index.js';
import {
collectHtmlImages,
collectSourceAssets,
+ buildNavigationAnchorCompatCss,
localizeCssImages,
REVEAL_NEUTRALIZER_CSS,
rewriteHtmlImageSrcs,
@@ -109,6 +110,39 @@ describe('source assets DLA parity', () => {
expect(WP_COMPAT_CSS).toBe(DLA_WP_COMPAT_CSS);
});
+ it('replays source nav anchor rules against core/navigation wrapper markup', () => {
+ const css = buildNavigationAnchorCompatCss(`
+.jumpnav a { padding: 0.35rem 0.85rem; color: var(--muted); text-decoration: none; }
+.jumpnav a:first-child { padding-left: 0; }
+.contact a { text-decoration: underline; }
+`);
+
+ expect(css).toContain(
+ '.jumpnav.wp-block-navigation .wp-block-navigation-item__content, .jumpnav .wp-block-navigation .wp-block-navigation-item__content { padding: 0.35rem 0.85rem; color: var(--muted); text-decoration: none; }'
+ );
+ expect(css).toContain(
+ '.jumpnav.wp-block-navigation .wp-block-navigation-item:first-child > .wp-block-navigation-item__content, .jumpnav .wp-block-navigation .wp-block-navigation-item:first-child > .wp-block-navigation-item__content { padding-left: 0; }'
+ );
+ expect(css).toContain(
+ '.contact.wp-block-navigation .wp-block-navigation-item__content, .contact .wp-block-navigation .wp-block-navigation-item__content { text-decoration: underline; }'
+ );
+ });
+
+ it('replays nested source nav anchor color, decoration, and border rules through core/navigation wrappers', () => {
+ const css = buildNavigationAnchorCompatCss(`
+.site-header .subnav a { color: #31251c; text-decoration: none; border-color: #31251c; }
+.site-header .subnav a:hover { color: #8f5031; border-color: #8f5031; }
+`);
+
+ expect(css).toContain(
+ '.site-header .subnav.wp-block-navigation .wp-block-navigation-item__content, .site-header .subnav .wp-block-navigation .wp-block-navigation-item__content { color: #31251c; text-decoration: none; border-color: #31251c; }'
+ );
+ expect(css).toContain(
+ '.site-header .subnav.wp-block-navigation .wp-block-navigation-item__content:hover, .site-header .subnav .wp-block-navigation .wp-block-navigation-item__content:hover { color: #8f5031; border-color: #8f5031; }'
+ );
+ expect(css).not.toContain('.site-header.wp-block-navigation .subnav');
+ });
+
it('rewrites exact quoted HTML image refs globally using the theme asset URL', () => {
const html =
'
Logo
';
diff --git a/packages/blocks-engine/src/theme/index.ts b/packages/blocks-engine/src/theme/index.ts
index 3b7225aa..7fc1f7bc 100644
--- a/packages/blocks-engine/src/theme/index.ts
+++ b/packages/blocks-engine/src/theme/index.ts
@@ -53,6 +53,7 @@ export { preserveDomStrategy } from './preserve-dom/strategy.js';
export {
collectHtmlImages,
collectSourceAssets,
+ buildNavigationAnchorCompatCss,
localizeCssImages,
rewriteHtmlImageSrcs,
WP_COMPAT_CSS,
diff --git a/packages/blocks-engine/src/theme/source-assets.ts b/packages/blocks-engine/src/theme/source-assets.ts
index 13f19f3f..4b94336c 100644
--- a/packages/blocks-engine/src/theme/source-assets.ts
+++ b/packages/blocks-engine/src/theme/source-assets.ts
@@ -409,7 +409,8 @@ export function collectSourceAssets(
// dir. Then strip Google-Fonts @imports: WP_COMPAT_CSS contains no @imports so
// startsWith(WP_COMPAT_CSS) is preserved; Google imports only exist in cssParts.
const { parts: localizedParts, mediaAssets } = localizeCssImages(cssParts, dir);
- const baseCss = (WP_COMPAT_CSS + localizedParts.join('\n\n')).replace(GOOGLE_IMPORT_RE, '');
+ const sourceCss = localizedParts.join('\n\n').replace(GOOGLE_IMPORT_RE, '');
+ const baseCss = WP_COMPAT_CSS + sourceCss + buildNavigationAnchorCompatCss(sourceCss);
// Append admin-bar accommodation LAST: scan the assembled source CSS for
// top-anchored fixed/sticky chrome and shift it below the WP admin bar for
// logged-in viewers (the bar otherwise overlays a `position:fixed; top:0`
@@ -429,3 +430,85 @@ export function collectSourceAssets(
imgRewritesByPage,
};
}
+
+export function buildNavigationAnchorCompatCss(sourceCss: string): string {
+ const rules: string[] = [];
+ for (const match of sourceCss.matchAll(/([^{}@][^{}]*)\{([^{}]*)\}/g)) {
+ const body = match[2]?.trim();
+ if (!body) continue;
+
+ const mappedSelectors = splitSelectorList(match[1] ?? '').flatMap(mapNavigationAnchorSelector);
+ if (mappedSelectors.length === 0) continue;
+
+ rules.push(`${Array.from(new Set(mappedSelectors)).join(', ')} { ${body} }`);
+ }
+
+ if (rules.length === 0) return '';
+
+ return `\n\n/* wp-compat: replay source nav anchor selectors against core/navigation wrapper markup */\n${rules.join('\n')}`;
+}
+
+function splitSelectorList(selectorList: string): string[] {
+ const selectors: string[] = [];
+ let current = '';
+ let depth = 0;
+
+ for (const char of selectorList) {
+ if (char === '(' || char === '[') depth += 1;
+ if (char === ')' || char === ']') depth = Math.max(0, depth - 1);
+ if (char === ',' && depth === 0) {
+ selectors.push(current.trim());
+ current = '';
+ continue;
+ }
+ current += char;
+ }
+
+ if (current.trim()) selectors.push(current.trim());
+ return selectors;
+}
+
+function mapNavigationAnchorSelector(selector: string): string[] {
+ const anchorMatch = /(^|[\s>+~])a(?=$|[\s:.#\[])/.exec(selector);
+ if (!anchorMatch) return [];
+
+ const anchorStart = anchorMatch.index + (anchorMatch[1] ?? '').length;
+ const prefix = selector.slice(0, anchorStart);
+ if (!/[.#\[]/.test(prefix)) return [];
+
+ const mapped = selector
+ .replace(/(\s*[>+~]?\s*)a:first-child\b/g, '$1.wp-block-navigation-item:first-child > .wp-block-navigation-item__content')
+ .replace(/(\s*[>+~]?\s*)a:last-child\b/g, '$1.wp-block-navigation-item:last-child > .wp-block-navigation-item__content')
+ .replace(/(\s*[>+~]?\s*)a:nth-child\(([^)]*)\)/g, '$1.wp-block-navigation-item:nth-child($2) > .wp-block-navigation-item__content')
+ .replace(/(\s*[>+~]?\s*)a(?![A-Za-z0-9_-])/g, '$1.wp-block-navigation-item__content');
+
+ const directWrapper = addNavigationClassToLastPrefixCompound(mapped, anchorStart);
+ const descendantWrapper = insertNavigationDescendantWrapper(mapped, prefix);
+ return Array.from(new Set([directWrapper, descendantWrapper].filter((value): value is string => Boolean(value))));
+}
+
+function addNavigationClassToLastPrefixCompound(selector: string, anchorStart: number): string | undefined {
+ const prefix = selector.slice(0, anchorStart);
+ const match = /([^\s>+~]+)(\s*[>+~]?\s*)$/.exec(prefix);
+ if (!match) return undefined;
+
+ const compound = match[1] ?? '';
+ if (compound.includes('.wp-block-navigation')) return selector;
+
+ const insertAt = compound.search(/:{1,2}/);
+ const mappedCompound = insertAt >= 0
+ ? `${compound.slice(0, insertAt)}.wp-block-navigation${compound.slice(insertAt)}`
+ : `${compound}.wp-block-navigation`;
+ const mappedPrefix = `${prefix.slice(0, match.index)}${mappedCompound}${match[2] ?? ''}`;
+ return `${mappedPrefix}${selector.slice(anchorStart)}`;
+}
+
+function insertNavigationDescendantWrapper(selector: string, prefix: string): string | undefined {
+ const parentPrefix = prefix.replace(/[\s>+~]+$/, '').trimEnd();
+ if (!parentPrefix || parentPrefix.includes('.wp-block-navigation')) return undefined;
+
+ const tail = selector.slice(prefix.length).replace(/^[\s>+~]+/, '');
+ if (!tail) return undefined;
+
+ return `${parentPrefix} .wp-block-navigation ${tail}`;
+}
diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php
index b985978e..42c2ad13 100644
--- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php
+++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php
@@ -527,7 +527,7 @@ private function themeFontLinkHtml(array $files): string
*
* @param array> $files
*/
- private function themeStaticCss(array $files): string
+ private function themeStaticCss(array $files, bool $includeNavigationCompat = true): string
{
$blocks = array();
foreach ( $files as $file ) {
@@ -551,7 +551,153 @@ private function themeStaticCss(array $files): string
}
}
- return implode("\n", array_keys($blocks));
+ $css = implode("\n", array_keys($blocks));
+
+ return $includeNavigationCompat ? $css . $this->navigationAnchorCompatCss($css) : $css;
+ }
+
+ private function navigationAnchorCompatCss(string $css): string
+ {
+ $rules = array();
+ if ( ! preg_match_all('/([^{}@][^{}]*)\{([^{}]*)\}/', $css, $matches, PREG_SET_ORDER) ) {
+ return '';
+ }
+
+ foreach ( $matches as $match ) {
+ $body = trim((string) ($match[2] ?? ''));
+ if ( '' === $body ) {
+ continue;
+ }
+
+ $mappedSelectors = array();
+ foreach ( $this->splitSelectorList((string) ($match[1] ?? '')) as $selector ) {
+ foreach ( $this->mapNavigationAnchorSelector($selector) as $mappedSelector ) {
+ $mappedSelectors[$mappedSelector] = true;
+ }
+ }
+
+ if ( array() !== $mappedSelectors ) {
+ $rules[] = implode(', ', array_keys($mappedSelectors)) . ' { ' . $body . ' }';
+ }
+ }
+
+ if ( array() === $rules ) {
+ return '';
+ }
+
+ return "\n\n/* wp-compat: replay source nav anchor selectors against core/navigation wrapper markup */\n" . implode("\n", $rules);
+ }
+
+ /**
+ * @return array
+ */
+ private function splitSelectorList(string $selectorList): array
+ {
+ $selectors = array();
+ $current = '';
+ $depth = 0;
+ $length = strlen($selectorList);
+ for ( $i = 0; $i < $length; $i++ ) {
+ $char = $selectorList[$i];
+ if ( '(' === $char || '[' === $char ) {
+ $depth++;
+ } elseif ( ')' === $char || ']' === $char ) {
+ $depth = max(0, $depth - 1);
+ }
+
+ if ( ',' === $char && 0 === $depth ) {
+ $selector = trim($current);
+ if ( '' !== $selector ) {
+ $selectors[] = $selector;
+ }
+ $current = '';
+ continue;
+ }
+
+ $current .= $char;
+ }
+
+ $selector = trim($current);
+ if ( '' !== $selector ) {
+ $selectors[] = $selector;
+ }
+
+ return $selectors;
+ }
+
+ /**
+ * @return array
+ */
+ private function mapNavigationAnchorSelector(string $selector): array
+ {
+ if ( ! preg_match('/(^|[\s>+~])a(?=$|[\s:.#\[])/', $selector, $anchorMatch, PREG_OFFSET_CAPTURE) ) {
+ return array();
+ }
+
+ $separator = (string) ($anchorMatch[1][0] ?? '');
+ $anchorStart = (int) $anchorMatch[0][1] + strlen($separator);
+ $prefix = substr($selector, 0, $anchorStart);
+ if ( 1 !== preg_match('/[.#\[]/', $prefix) ) {
+ return array();
+ }
+
+ $mapped = preg_replace('/(\s*[>+~]?\s*)a:first-child\b/', '$1.wp-block-navigation-item:first-child > .wp-block-navigation-item__content', $selector);
+ $mapped = preg_replace('/(\s*[>+~]?\s*)a:last-child\b/', '$1.wp-block-navigation-item:last-child > .wp-block-navigation-item__content', (string) $mapped);
+ $mapped = preg_replace('/(\s*[>+~]?\s*)a:nth-child\(([^)]*)\)/', '$1.wp-block-navigation-item:nth-child($2) > .wp-block-navigation-item__content', (string) $mapped);
+ $mapped = preg_replace('/(\s*[>+~]?\s*)a(?![A-Za-z0-9_-])/', '$1.wp-block-navigation-item__content', (string) $mapped);
+ $mapped = (string) $mapped;
+
+ $selectors = array();
+ $directWrapper = $this->addNavigationClassToLastPrefixCompound($mapped, $anchorStart);
+ if ( null !== $directWrapper ) {
+ $selectors[$directWrapper] = true;
+ }
+ $descendantWrapper = $this->insertNavigationDescendantWrapper($mapped, $prefix);
+ if ( null !== $descendantWrapper ) {
+ $selectors[$descendantWrapper] = true;
+ }
+
+ return array_keys($selectors);
+ }
+
+ private function addNavigationClassToLastPrefixCompound(string $selector, int $anchorStart): ?string
+ {
+ $prefix = substr($selector, 0, $anchorStart);
+ if ( ! preg_match('/([^\s>+~]+)(\s*[>+~]?\s*)$/', $prefix, $match, PREG_OFFSET_CAPTURE) ) {
+ return null;
+ }
+
+ $compound = (string) ($match[1][0] ?? '');
+ if ( str_contains($compound, '.wp-block-navigation') ) {
+ return $selector;
+ }
+
+ $pseudoOffset = false;
+ if ( preg_match('/:{1,2}/', $compound, $pseudoMatch, PREG_OFFSET_CAPTURE) ) {
+ $pseudoOffset = (int) $pseudoMatch[0][1];
+ }
+
+ $mappedCompound = false === $pseudoOffset
+ ? $compound . '.wp-block-navigation'
+ : substr($compound, 0, $pseudoOffset) . '.wp-block-navigation' . substr($compound, $pseudoOffset);
+ $mappedPrefix = substr($prefix, 0, (int) $match[1][1]) . $mappedCompound . (string) ($match[2][0] ?? '');
+
+ return $mappedPrefix . substr($selector, $anchorStart);
+ }
+
+ private function insertNavigationDescendantWrapper(string $selector, string $prefix): ?string
+ {
+ $parentPrefix = rtrim((string) preg_replace('/[\s>+~]+$/', '', $prefix));
+ if ( '' === $parentPrefix || str_contains($parentPrefix, '.wp-block-navigation') ) {
+ return null;
+ }
+
+ $tail = ltrim((string) preg_replace('/^[\s>+~]+/', '', substr($selector, strlen($prefix))));
+ if ( '' === $tail ) {
+ return null;
+ }
+
+ return $parentPrefix . ' .wp-block-navigation ' . $tail;
}
/**
@@ -1236,7 +1382,7 @@ private function compiledSiteReport(array $artifact, string $entryPath, array $d
'pages' => $pages,
'assets' => $this->compiledSiteAssets($assets),
'template_parts' => $this->compiledSiteTemplateParts($artifact['files']),
- 'visual_repair' => $this->compiledSiteVisualRepair($assets),
+ 'visual_repair' => $this->compiledSiteVisualRepair($assets, $artifact['files']),
'theme' => array_filter(
array(
'stylesheets' => $this->assetPathsByIntentOrRole($assets, 'style', 'stylesheet'),
@@ -1489,7 +1635,7 @@ private function templatePartArea(string $path, string $role): string
* @param array> $assets
* @return array
*/
- private function compiledSiteVisualRepair(array $assets): array
+ private function compiledSiteVisualRepair(array $assets, array $files): array
{
$stylesheets = array_values(array_filter($assets, fn (array $asset): bool => $this->isVisualRepairStylesheet($asset)));
$css = '';
@@ -1498,6 +1644,10 @@ private function compiledSiteVisualRepair(array $assets): array
$css .= ('' === $css ? '' : "\n") . $asset['content'];
}
}
+ $navigationCompatCss = $this->navigationAnchorCompatCss($this->themeStaticCss($files, false));
+ if ( '' !== $navigationCompatCss ) {
+ $css .= ('' === $css ? '' : "\n") . $navigationCompatCss;
+ }
return array_filter(
array(
diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php
index edfcdbb3..2f749eb0 100644
--- a/php-transformer/tests/contract/run.php
+++ b/php-transformer/tests/contract/run.php
@@ -1546,6 +1546,22 @@ public function match(DOMElement $element, PatternContext $context): ?array
$assert(MaterializationPlanBuilder::SCHEMA === ($simple['source_reports']['materialization_plan']['schema'] ?? ''), 'artifact exposes canonical materialization plan');
$assert('index.html' === ($simple['source_reports']['materialization_plan']['entry_path'] ?? ''), 'materialization plan exposes entry path');
+$artifactNavAnchorCss = $compiler->compile(
+ array(
+ 'entry' => 'index.html',
+ 'files' => array(
+ 'index.html' => '',
+ 'styles.css' => '.site-header .subnav a{color:#31251c;text-decoration:none;border-color:#31251c}.site-header .subnav a:hover{color:#8f5031;border-color:#8f5031}',
+ ),
+ )
+)->toArray();
+$artifactNavAnchorStaticCss = (string) ($artifactNavAnchorCss['source_reports']['compiled_site']['theme']['static_css'] ?? '');
+$assert(str_contains($artifactNavAnchorStaticCss, '.site-header .subnav.wp-block-navigation .wp-block-navigation-item__content, .site-header .subnav .wp-block-navigation .wp-block-navigation-item__content { color:#31251c;text-decoration:none;border-color:#31251c }'), 'artifact static CSS replays nested nav anchor color through direct and descendant core/navigation wrappers');
+$assert(str_contains($artifactNavAnchorStaticCss, '.site-header .subnav.wp-block-navigation .wp-block-navigation-item__content:hover, .site-header .subnav .wp-block-navigation .wp-block-navigation-item__content:hover { color:#8f5031;border-color:#8f5031 }'), 'artifact static CSS replays nested nav anchor hover color through core/navigation wrappers');
+$assert(! str_contains($artifactNavAnchorStaticCss, '.site-header.wp-block-navigation .subnav'), 'artifact static CSS does not attach core/navigation to the wrong ancestor selector');
+$artifactNavAnchorRepairCss = (string) ($artifactNavAnchorCss['source_reports']['compiled_site']['visual_repair']['css'] ?? '');
+$assert(str_contains($artifactNavAnchorRepairCss, '.site-header .subnav.wp-block-navigation .wp-block-navigation-item__content, .site-header .subnav .wp-block-navigation .wp-block-navigation-item__content { color:#31251c;text-decoration:none;border-color:#31251c }'), 'artifact visual repair CSS carries nav anchor replay for downstream theme materializers');
+
$artifactInlineSvg = $compiler->compile(
array(
'schema' => ArtifactCompiler::INPUT_SCHEMA,