Skip to content

Fix: GDPR banner duplicate controls & WPML/Polylang i18n#147

Merged
parhumm merged 2 commits intodevelopmentfrom
fix/gdpr-banner-144-145
Mar 9, 2026
Merged

Fix: GDPR banner duplicate controls & WPML/Polylang i18n#147
parhumm merged 2 commits intodevelopmentfrom
fix/gdpr-banner-144-145

Conversation

@parhumm
Copy link
Copy Markdown
Contributor

@parhumm parhumm commented Mar 9, 2026

Summary

Closes #144
Closes #145

Test plan

  • Set opt_out_message to Click <a href="#">Accept</a> or <a href="#">Deny</a> — verify anchor-only links are stripped, text preserved, only <button> controls visible
  • Set opt_out_message to Read our <a href="https://example.com/privacy">Privacy Policy</a> — verify real URL link is preserved and clickable
  • Verify <p>, <br>, <b>, <i>, <strong>, <em> tags still render in banner message
  • Without WPML/Polylang: verify banner renders as before with no warnings/errors
  • With WPML: save custom strings → verify they appear in String Translation under context wp-slimstat → verify translated values render on language switch
  • With Polylang: save custom strings → verify they are registered and translatable
  • Empty message falls back to default __() string; empty button text falls back to __('Accept')/__('Deny')
  • Banner hidden when consent cookie exists; Accept/Deny buttons still set cookie correctly

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • GDPR consent banner and its Accept/Deny button texts now support multilingual translations (WPML/Polylang), so configured messages display in the site language.
  • Documentation

    • Consent banner guidance updated: links must be full URLs; anchor-only links are stripped to ensure consistent, sanitized banner content.

#145)

- Strip anchor-only/empty-href links from custom banner message to
  prevent duplicate Accept/Deny controls alongside rendered buttons.
  Real URL links (e.g. privacy policy) are preserved.
- Register custom GDPR banner strings (message, accept/decline labels)
  with WPML (wpml_register_single_string) and Polylang (pll_register_string)
  on settings save, and apply translations at render time via
  wpml_translate_single_string with native Polylang fallback.
- Update banner message field description to clarify link requirements.
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 9, 2026

📝 Walkthrough

Walkthrough

Admin settings now register custom GDPR banner strings with WPML/Polylang after save; the GDPR service translates banner text and button labels at render time and strips anchor-only links from custom messages to avoid duplicate interactive controls.

Changes

Cohort / File(s) Summary
Admin Config & Translation Registration
admin/config/index.php
Updated consent banner description to require full URLs; added post-save logic that registers opt_out_message, gdpr_accept_button_text, and gdpr_decline_button_text with WPML (do_action('wpml_register_single_string', ...)) and Polylang (pll_register_string) when available.
Banner Service Translation & Link Sanitization
src/Services/GDPRService.php
Added private translateString(string $value, string $name) helper used in getBannerHtml() to resolve WPML/Polylang translations with fallback. opt_out_message is sanitized to strip anchor-only/empty-href links; accept/decline button texts are passed through translateString() before rendering.

Sequence Diagram(s)

sequenceDiagram
  participant Admin as "Admin UI"
  participant Settings as "Settings Save Handler"
  participant WPML as "WPML API"
  participant PLL as "Polylang API"
  participant Frontend as "GDPRService (render)"

  rect rgba(200,200,255,0.5)
  Admin->>Settings: Save consent banner & button texts
  Settings->>WPML: do_action('wpml_register_single_string', ... ) (if available)
  Settings->>PLL: pll_register_string(...) (if function exists)
  end

  rect rgba(200,255,200,0.5)
  Frontend->>WPML: request translation via translateString (if available)
  Frontend->>PLL: request translation via translateString (if available)
  Frontend->>Frontend: strip anchor-only / empty-href links from message
  Frontend->>Frontend: render translated message and buttons
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I hopped through settings, strings in my paws,
Registered words for many locale laws,
I nibbled links that caused two buttons to be,
Now banners speak clearly for you and for me!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main changes: fixing duplicate GDPR banner controls and adding WPML/Polylang i18n support, matching the core objectives.
Linked Issues check ✅ Passed The PR addresses both linked issues: stripping anchor-only links to fix duplicate controls (#144) and registering translatable strings with WPML/Polylang (#145) for full i18n support.
Out of Scope Changes check ✅ Passed All changes are directly scoped to the two linked issues: admin config description updates, translation registration logic, and banner text sanitization/translation.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/gdpr-banner-144-145

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@parhumm
Copy link
Copy Markdown
Contributor Author

parhumm commented Mar 9, 2026

Human QA Checklist

#144 — Duplicate Controls Fix

  • Anchor-only links stripped: Set opt_out_message to Click <a href="#">Accept</a> or <a href="#">Deny</a> to continue via DB → visit frontend (clear slimstat_gdpr_consent cookie first) → confirm "Accept" and "Deny" render as plain text, no <a> tags in DOM
  • Real URL links preserved: Set opt_out_message to Read our <a href="https://example.com/privacy">Privacy Policy</a> via settings → confirm link is clickable and intact in the banner
  • Empty-href links stripped: Set opt_out_message to <a href="">Click here</a> → confirm link is stripped, text preserved
  • Formatting tags intact: Set opt_out_message with <p>, <br>, <b>, <i>, <strong>, <em> → confirm all render correctly
  • Only banner buttons actionable: With any custom message, confirm only the two <button> elements (Accept/Deny) trigger consent actions

#145 — WPML/Polylang i18n

Without translation plugins

  • No errors/warnings: With no WPML or Polylang active, save custom values for all three GDPR strings → visit frontend → confirm banner renders custom text with no PHP warnings or errors
  • Default fallback: Leave all three fields empty → confirm banner uses default __() strings ("This website uses cookies…", "Accept", "Deny")

With WPML

  • String registration: Save custom GDPR message + button labels → go to WPML > String Translation → search context wp-slimstat → confirm all three strings appear
  • Translation renders: Add translations for the strings → switch site language → confirm banner shows translated text

With Polylang

  • String registration: Save custom GDPR message + button labels → go to Languages > String Translations → search group wp-slimstat → confirm all three strings appear
  • Translation renders: Add translations → switch language → confirm banner shows translated text

Regression

  • Banner hidden after consent: Click Accept or Deny → refresh page → confirm banner does not reappear
  • Cookie set correctly: After clicking Accept → check slimstat_gdpr_consent cookie equals accepted; after Deny → equals denied
  • Banner shows for new visitors: Clear all cookies → visit frontend → confirm banner appears
  • Settings save works: Change any GDPR setting → save → confirm success message and values persist on reload
  • No unescaped output: Inspect banner HTML source → confirm message content is inside wp_kses_post() output, button text is esc_html() escaped

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/Services/GDPRService.php`:
- Around line 210-212: The preg_replace call that strips anchor-only links in
GDPRService.php currently only matches empty href and bare hash but misses named
anchors; update the regex used in the preg_replace on $message so the href value
matcher accepts either an empty string or a value that begins with a '#'
(including named anchors like "#accept" or "#deny") while still preserving real
URLs — modify the pattern passed to preg_replace in the GDPRService:: (or
surrounding) method so the href value part is an optional group that matches ""
or "#" followed by optional word characters, then keep the replacement as '$1'.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4e07e0af-d0d7-4c0d-be47-fd92cbc42e8a

📥 Commits

Reviewing files that changed from the base of the PR and between ca09fe6 and 5222102.

📒 Files selected for processing (2)
  • admin/config/index.php
  • src/Services/GDPRService.php

The previous preg_replace pattern only handled href="#" and href=""
but missed named anchors (href="#accept", href="#deny") and
whitespace variants (href=" # "). Replace with preg_replace_callback
that whitelists real URLs (http(s)://, /path) and strips everything
else, preserving inner text content.

Tested against 15 edge cases covering all known variants.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/Services/GDPRService.php (1)

155-169: Polylang fallback may silently fail to translate.

The pll__() function looks up translations by the original string value, not by name. If Polylang's WPML compatibility layer is disabled, the wpml_translate_single_string filter returns the original value unchanged, then pll__($value) is called—but this only works if $value exactly matches what was registered. If the stored setting differs even slightly from the registered string (whitespace, encoding), the lookup fails silently.

Consider passing the name to Polylang's internal lookup or documenting that the WPML compatibility API must be enabled in Polylang for translations to work reliably.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Services/GDPRService.php` around lines 155 - 169, The Polylang fallback
can miss translations when the stored setting doesn't exactly match the original
string; update translateString to, after the wpml_translate_single_string filter
returns the original, prefer Polylang's name-based lookup if available (call
pll_translate_string or any Polylang API that accepts the registration name),
and only then fall back to pll__($value); keep the existing checks for
function_exists('pll__') and add a short doc comment noting that reliable
translations require either Polylang's name-based lookup or Polylang's WPML
compatibility API to be enabled. Ensure references to translateString,
wpml_translate_single_string, pll_translate_string (if present) and pll__ are
used so reviewers can locate the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/Services/GDPRService.php`:
- Around line 210-223: The regex used in the preg_replace_callback that
processes $message (the anonymous function capturing $href and using preg_match)
currently only matches URLs starting with "http(s)://" or a single "/" so
protocol-relative URLs like "//example.com" are stripped despite the comment;
update the preg_match pattern to accept protocol-relative URLs (for example
change '#^(https?://|/)#i' to a pattern that includes '//' such as
'#^((https?://)|//|/)#i' or '#^((https?:)?//|/)#i') so links beginning with "//"
are preserved, and keep the surrounding logic in the anonymous function
unchanged.

---

Nitpick comments:
In `@src/Services/GDPRService.php`:
- Around line 155-169: The Polylang fallback can miss translations when the
stored setting doesn't exactly match the original string; update translateString
to, after the wpml_translate_single_string filter returns the original, prefer
Polylang's name-based lookup if available (call pll_translate_string or any
Polylang API that accepts the registration name), and only then fall back to
pll__($value); keep the existing checks for function_exists('pll__') and add a
short doc comment noting that reliable translations require either Polylang's
name-based lookup or Polylang's WPML compatibility API to be enabled. Ensure
references to translateString, wpml_translate_single_string,
pll_translate_string (if present) and pll__ are used so reviewers can locate the
change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 2a55b5e3-8615-4e67-b34b-a91b84459244

📥 Commits

Reviewing files that changed from the base of the PR and between 5222102 and 632aed6.

📒 Files selected for processing (1)
  • src/Services/GDPRService.php

Comment on lines +210 to +223
// Strip links that don't point to real URLs (legacy accept/deny controls)
// Keeps: https://..., http://..., /path, //protocol-relative
// Strips: #, #fragment, empty, whitespace-only — preserves inner text
$message = preg_replace_callback(
'/<a\s[^>]*href\s*=\s*["\']([^"\']*)["\'][^>]*>(.*?)<\/a>/is',
function ($matches) {
$href = trim($matches[1]);
if (preg_match('#^(https?://|/)#i', $href)) {
return $matches[0]; // Real URL — keep the link
}
return $matches[2]; // Not a real URL — keep text, strip tag
},
$message
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Protocol-relative URLs are not preserved despite the comment.

The comment on line 211 states //protocol-relative URLs are kept, but the regex ^(https?://|/) requires exactly one leading slash, so //example.com would be stripped. This is likely a minor edge case since protocol-relative URLs are deprecated practice, but the comment is misleading.

🔧 Option to fix the pattern if protocol-relative URLs matter
-			if (preg_match('#^(https?://|/)#i', $href)) {
+			if (preg_match('#^(https?://|//)#i', $href)) {

Or update the comment to remove the protocol-relative mention if intentionally unsupported.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Strip links that don't point to real URLs (legacy accept/deny controls)
// Keeps: https://..., http://..., /path, //protocol-relative
// Strips: #, #fragment, empty, whitespace-only — preserves inner text
$message = preg_replace_callback(
'/<a\s[^>]*href\s*=\s*["\']([^"\']*)["\'][^>]*>(.*?)<\/a>/is',
function ($matches) {
$href = trim($matches[1]);
if (preg_match('#^(https?://|/)#i', $href)) {
return $matches[0]; // Real URL — keep the link
}
return $matches[2]; // Not a real URL — keep text, strip tag
},
$message
);
// Strip links that don't point to real URLs (legacy accept/deny controls)
// Keeps: https://..., http://..., /path, //protocol-relative
// Strips: #, `#fragment`, empty, whitespace-only — preserves inner text
$message = preg_replace_callback(
'/<a\s[^>]*href\s*=\s*["\']([^"\']*)["\'][^>]*>(.*?)<\/a>/is',
function ($matches) {
$href = trim($matches[1]);
if (preg_match('#^(https?://|//)#i', $href)) {
return $matches[0]; // Real URL — keep the link
}
return $matches[2]; // Not a real URL — keep text, strip tag
},
$message
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Services/GDPRService.php` around lines 210 - 223, The regex used in the
preg_replace_callback that processes $message (the anonymous function capturing
$href and using preg_match) currently only matches URLs starting with
"http(s)://" or a single "/" so protocol-relative URLs like "//example.com" are
stripped despite the comment; update the preg_match pattern to accept
protocol-relative URLs (for example change '#^(https?://|/)#i' to a pattern that
includes '//' such as '#^((https?://)|//|/)#i' or '#^((https?:)?//|/)#i') so
links beginning with "//" are preserved, and keep the surrounding logic in the
anonymous function unchanged.

@parhumm parhumm merged commit 8aa7660 into development Mar 9, 2026
1 check passed
@parhumm parhumm deleted the fix/gdpr-banner-144-145 branch March 9, 2026 14:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant