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
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as theme from '../theme/index.js';
import {
collectHtmlImages,
collectSourceAssets,
buildNavigationAnchorCompatCss,
localizeCssImages,
REVEAL_NEUTRALIZER_CSS,
rewriteHtmlImageSrcs,
Expand Down Expand Up @@ -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 =
'<main><img src="img/logo.png"><a href="img/logo.png">Logo</a><img src=\'img/logo.png\'><span data-copy=\'img/logo.png\' data-other="icons/logo.png"></span></main>';
Expand Down
1 change: 1 addition & 0 deletions packages/blocks-engine/src/theme/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export { preserveDomStrategy } from './preserve-dom/strategy.js';
export {
collectHtmlImages,
collectSourceAssets,
buildNavigationAnchorCompatCss,
localizeCssImages,
rewriteHtmlImageSrcs,
WP_COMPAT_CSS,
Expand Down
85 changes: 84 additions & 1 deletion packages/blocks-engine/src/theme/source-assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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}`;
}
158 changes: 154 additions & 4 deletions php-transformer/src/ArtifactCompiler/ArtifactCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -527,7 +527,7 @@ private function themeFontLinkHtml(array $files): string
*
* @param array<int, array<string, mixed>> $files
*/
private function themeStaticCss(array $files): string
private function themeStaticCss(array $files, bool $includeNavigationCompat = true): string
{
$blocks = array();
foreach ( $files as $file ) {
Expand All @@ -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<int, string>
*/
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<int, string>
*/
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;
}

/**
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -1489,7 +1635,7 @@ private function templatePartArea(string $path, string $role): string
* @param array<int, array<string, mixed>> $assets
* @return array<string, mixed>
*/
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 = '';
Expand All @@ -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(
Expand Down
16 changes: 16 additions & 0 deletions php-transformer/tests/contract/run.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => '<!doctype html><html><head><link rel="stylesheet" href="styles.css"></head><body><header class="site-header"><nav class="subnav"><a href="#one">One</a></nav></header></body></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,
Expand Down
Loading