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,