to hide elements before React renders them,
* preventing visual "flashing". Converts XPath to CSS where possible, or uses
* saved css_selector from rules.
*
* @since 1.0.1
*/
final class InlineCssGenerator {
private const CACHE_KEY = 'adsd_inline_css_cache';
private const CACHE_DURATION = 300; // 5 minutes
private const MAX_RULES = 50;
private const MAX_CSS_LENGTH = 10240; // 10KB
private const MAX_SELECTOR_LENGTH = 512;
/**
* Characters allowed in a CSS selector.
*
* Selectors partly come from the browser, so they are validated before being
* printed inside a `';
}
/**
* Generate CSS from all active rules.
* FrontendHider will determine which elements actually need hiding.
*
* @since 1.0.1
* @return string Generated CSS
*/
private function generate_css(): string {
$rules = $this->options_repository->get_active_rules();
if (empty($rules)) {
return '';
}
// Limit rules
$rules = array_slice($rules, 0, self::MAX_RULES, true);
// Get rules that failed on backend (element not found in server HTML)
$page_key = PageContext::get_page_cache_key();
$failed_rule_ids = get_transient('adsd_failed_rules_' . $page_key);
if ($failed_rule_ids === false) {
// First load after rule creation - no transient yet.
// Let the server-side pass try first; the next load knows what failed.
return '';
}
if (empty($failed_rule_ids)) {
// All rules were successfully applied on backend
return '';
}
$selectors = [];
foreach ($rules as $rule) {
// Skip rules that were successfully applied on backend
if (!in_array($rule['id'], $failed_rule_ids, true)) {
continue;
}
// Skip rules recorded on another screen
if (!PageContext::rule_applies_here($rule)) {
continue;
}
$selectors = array_merge($selectors, $this->get_all_css_selectors_for_rule($rule));
}
$selectors = array_unique($selectors);
if (empty($selectors)) {
return '';
}
// Generate CSS with all selectors
$css = implode(',', $selectors) . '{display:none!important;visibility:hidden!important;}';
// Check length limit
if (strlen($css) > self::MAX_CSS_LENGTH) {
$this->logger->warning('Generated CSS exceeds limit', ['length' => strlen($css)]);
return '';
}
return $css;
}
/**
* Get ALL CSS selectors for a rule (both XPath→CSS and saved css_selector).
*
* @since 1.0.1
* @param array $rule Rule data
* @return array Array of CSS selectors
*/
private function get_all_css_selectors_for_rule(array $rule): array {
$selectors = [];
// Strategy 1: Convert XPath to CSS
if (!empty($rule['xpath'])) {
$css = $this->xpath_to_css((string) $rule['xpath']);
if ($css !== null) {
$css = $this->validate_selector($css);
if ($css !== null) {
$selectors[] = $css;
}
}
}
// Strategy 2: Use the selector generated by the browser (fallback)
if (!empty($rule['css_selector'])) {
$selector = $this->validate_selector((string) $rule['css_selector']);
if ($selector !== null) {
$selectors[] = $selector;
} else {
$this->logger->warning('Rejected unsafe CSS selector', ['rule_id' => $rule['id'] ?? '']);
}
}
return $selectors;
}
/**
* Validate a CSS selector before it is printed into a style block.
*
* @since 1.0.29
* @param string $selector Selector to validate.
* @return string|null Trimmed selector or null when it is not safe.
*/
private function validate_selector(string $selector): ?string {
$selector = trim($selector);
if ($selector === '' || strlen($selector) > self::MAX_SELECTOR_LENGTH) {
return null;
}
if (!preg_match(self::SELECTOR_PATTERN, $selector)) {
return null;
}
if (strpos($selector, '/*') !== false || strpos($selector, '*/') !== false) {
return null;
}
return $selector;
}
/**
* Convert XPath to a CSS selector (the forms the picker generates).
*
* Supported patterns:
* - //*[@id="X"] → #X
* - //*[@id="X"]/div[2]/span → #X > div:nth-of-type(2) > span
* - //div[contains(concat(" ", normalize-space(@class), " "), " a ")] → div.a
* - //*[@id="X"]//div[contains(...)] → #X div.a
* - //div[@data-id="v"] → div[data-id="v"]
*
* Positions in XPath count siblings of the same tag, which is :nth-of-type
* in CSS, not :nth-child.
*
* @since 1.0.1
* @param string $xpath XPath expression
* @return string|null CSS selector or null if conversion not possible
*/
public function xpath_to_css(string $xpath): ?string {
$xpath = trim($xpath);
if ($xpath === '' || strncmp($xpath, '//', 2) !== 0) {
return null;
}
// Split into steps, keeping the descendant ("//") combinator.
$tokens = preg_split('#(//|/)#', $xpath, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
if (!is_array($tokens) || $tokens === []) {
return null;
}
$css = '';
$combinator = '';
foreach ($tokens as $token) {
if ($token === '//') {
$combinator = $css === '' ? '' : ' ';
continue;
}
if ($token === '/') {
$combinator = $css === '' ? '' : ' > ';
continue;
}
$step = $this->step_to_css($token);
if ($step === null) {
return null;
}
$css .= $combinator . $step;
$combinator = '';
}
return $css === '' ? null : $css;
}
/**
* Convert a single XPath step to CSS.
*
* @since 1.0.31
* @param string $step Step such as `div[2]` or `*[@id="x"]`.
* @return string|null CSS fragment or null when the step is not supported.
*/
private function step_to_css(string $step): ?string {
if (!preg_match('/^(\*|[a-z][a-z0-9]*)((?:\[[^\]]*\])*)$/i', $step, $matches)) {
return null;
}
$tag = strtolower($matches[1]);
$css = $tag === '*' ? '' : $tag;
if ($matches[2] === '') {
return $css === '' ? '*' : $css;
}
if (!preg_match_all('/\[([^\]]*)\]/', $matches[2], $predicates)) {
return null;
}
foreach ($predicates[1] as $predicate) {
$predicate = trim($predicate);
if (preg_match('/^\d+$/', $predicate)) {
$css .= ':nth-of-type(' . (int) $predicate . ')';
continue;
}
if (preg_match('/^@id=(["\'])([^"\']+)\1$/', $predicate, $id_match)) {
$css .= '#' . $id_match[2];
continue;
}
if (preg_match('/normalize-space\(@class\).*?,\s*(["\'])\s([^"\']+)\s\1\s*\)$/', $predicate, $class_match)) {
$css .= '.' . $class_match[2];
continue;
}
if (preg_match('/^@(data-[a-z0-9_-]+)=(["\'])([^"\']*)\2$/i', $predicate, $attr_match)) {
$css .= '[' . strtolower($attr_match[1]) . '="' . $attr_match[3] . '"]';
continue;
}
return null;
}
return $css;
}
/**
* Get cached CSS.
*
* @since 1.0.1
* @return string|null Cached CSS or null
*/
private function get_cached_css(): ?string {
$cached = get_transient(self::CACHE_KEY . '_' . PageContext::get_page_cache_key());
return is_string($cached) ? $cached : null;
}
/**
* Cache CSS.
*
* @since 1.0.1
* @param string $css CSS to cache
*/
private function cache_css(string $css): void {
PageContext::set_tracked_transient(
self::CACHE_KEY . '_' . PageContext::get_page_cache_key(),
$css,
self::CACHE_DURATION
);
}
/**
* Invalidate CSS cache.
*
* @since 1.0.1
*/
public function invalidate_cache(): void {
PageContext::flush_tracked_transients();
}
}