Skip to content
Get a free audit

Currency

Project Spark 8 min read

The Invisible-Character Bug: Hebrew Titles and Duplicate Brand Suffixes

How invisible Unicode characters caused duplicate brand names in Hebrew title tags, and the render-time guard that fixed it for every language.

Bernard

A client’s Hebrew pages started rendering titles like “Page Name | Brand – Brand”, the brand appended twice, sometimes in two different scripts. The root cause was a chain of reasonable-looking behaviors colliding: stored titles that already contained the brand, WordPress’s template appending it again, and invisible Unicode characters defeating the duplicate check. This post is the anatomy of that bug and the fix that holds in every language.

It is written as a detective story because that is how it unfolded: a symptom that looked impossible, three separate mechanisms hiding behind one comparison, and a fix whose final form was shaped as much by two wrong fixes as by the right one.

Key takeaway: Any “does this title already contain the brand?” check that uses plain string comparison will fail on real-world multilingual data. Correct duplicate detection requires stripping invisible characters, Unicode NFC normalization, and case folding, before comparing anything.

The report: a title that doubled itself

The report arrived with a screenshot from live search results: a page title carrying the brand name twice, joined by two different separators, in two scripts. The immediate confusion was that the code responsible looked correct. There was a stored SEO title; there was a check asking whether the stored title already contained the site name; the check said no; the platform appended the brand. Every step behaved as designed, and the output was wrong anyway.

That combination, locally correct steps, globally wrong result, is the signature of a data problem rather than a logic problem. The stored title did contain the brand. The comparison simply could not see it.

How the duplication happens

WordPress core appends the site name to document titles by default. That’s usually right. But titles imported from a previous SEO plugin, or hand-written by editors, often already include the brand, with the editor’s chosen separator. Append again and you get doubled brand chrome.

The mixed-script variant made my client’s case especially visible: the stored title carried the brand in one script, the platform appended it in another, so the doubled result displayed two different spellings of the same business, obvious to every customer, invisible to every string comparison.

The naive fix is obvious: check whether the stored title contains the site name and skip the append. And the naive fix fails in production, for three reasons:

1. Invisible characters break equality

Hebrew text pasted from chat apps and word processors carries right-to-left marks (U+200F), left-to-right marks, zero-width joiners, and directional embeddings. These characters exist for legitimate reasons, they make mixed-direction text display correctly in the tools where the text was originally written, but copy-paste transports them into places they were never meant to live.

The stored title contained the brand plus a hidden RLM; the site name didn’t. “Contains” returned false. Brand appended. Duplicate shipped. Diagnosis required inspecting the string at the codepoint level; nothing at the character-display level showed any difference at all.

2. Unicode has multiple spellings for the same text

Accented and composed characters can be encoded as one codepoint or several (base + combining marks). Visually identical, byte-different. Two independently typed copies of the same brand name can disagree at the byte level depending on the operating system, keyboard, and application that produced them. Comparisons must normalize both sides to NFC first, the canonical composed form, before any equality test means what a human thinks it means.

3. Case and script variants

Brand names live in multiple cases and sometimes multiple scripts. Comparison must be case-insensitive using multibyte-aware lowercasing, the ASCII-only variety silently no-ops on non-Latin text, which means a check that appears case-insensitive in English is case-sensitive in every other alphabet. That partial correctness is worse than a plain failure, because English-language testing will never expose it.

Reproduce it yourself in five minutes

The failure is easy to demonstrate, and doing so once permanently changes how you read string comparisons. Copy a sentence from a chat application into any tool that reveals Unicode codepoints (search for a “Unicode inspector”, several run in the browser). If the text is Hebrew or Arabic, or was ever near mixed-direction content, you will very likely find directional marks that no editor displays. Now paste the same visible sentence typed fresh from a keyboard, and compare: identical on screen, different in codepoints, and unequal in any programming language’s default comparison.

That five-minute experiment explains support tickets across the entire software industry, the record that “exists but can’t be found,” the duplicate that “was already entered,” the validation that “rejects correct input.” The characters are doing their designed job; the comparisons are simply operating below the level at which humans perceive text. Once you have seen it, you write different code.

Two wrong fixes first

The final design was the third attempt, and the two failures shaped it.

Wrong fix one: clean the data. The instinct was to strip brand suffixes during import, so stored titles would never contain the brand and the append could proceed safely. This violates the data-faithfulness contract from Part 6, it mutates user content based on a heuristic, and it broke separators in the process, replacing editors’ deliberate choices with platform defaults. Duplication is a presentation problem; solving it by rewriting data destroys information that can never be recovered.

Wrong fix two: guard conditionally. The next version ran the duplicate check only when a related plugin feature was enabled, on the assumption that duplication could only arise through that feature. Bug reports disproved the assumption within weeks: duplicated brands arrived through imports, through hand-typed titles, through content edited long before the plugin existed. The lesson: a guard that protects against a data condition must not be gated behind a feature toggle, because the data condition can arrive from anywhere.

The fix: an unconditional render-time guard

The final design puts one guard at the single point where WordPress composes the document title. It normalizes both the stored title and site name (strip invisibles → NFC → multibyte case-fold) and, when the brand is already present, tells WordPress not to append its own copy. No stored data is touched, consistent with the store-verbatim-render-smart principle from Part 2.

Two design details worth adopting:

  • Unconditional. The guard always runs, for every title, in every language. It is cheap, a normalization and a containment check at render time, and the alternative, as the second wrong fix demonstrated, is a guard that misses exactly the cases nobody predicted.
  • Render-time, not import-time. Presentation problems get presentation-layer fixes. A render-time guard corrects every page instantly when it ships, requires no data migration, and is fully reversible, three properties no storage-side “cleanup” can offer.

The normalization pipeline inside the guard is the reusable artifact: strip invisible formatting characters, normalize to NFC, case-fold with multibyte-aware functions, then compare. That sequence belongs in every equality or containment check that touches user-entered multilingual text, deduplication, search, validation, anywhere strings meet human input.

Where else this bug family lives

The doubled title is one member of a large family, and recognizing the family is worth more than fixing the instance. Any system that compares human-entered text against human-entered text carries the same exposure. Duplicate detection in content audits: two “identical” descriptions that fail to deduplicate because one carries a directional mark. Search within an admin interface: a query that cannot find a record whose stored form differs only in normalization. Keyword matching in internal-linking tools: an anchor phrase that never matches because the stored keyword was pasted from a chat application. Validation rules: a “this field must equal that field” check that fails for reasons no user can see or correct.

Project Spark applies the same normalization pipeline at every one of these comparison points, and the test corpus for each includes strings salted with invisible characters, because a defense that exists in one place and not another is a defense the next feature will forget. The broader habit transfers to any codebase: when a bug reveals a class (here: naive comparison of multilingual text), sweep every comparison site in the system before closing the ticket, and encode the sweep’s findings as tests. The alternative is fixing the same bug annually under different names.

Regression-proofing

The exact user-reported Hebrew strings (anonymized in tests) are pinned in the automated suite: the precise input that once produced a doubled title now has a named test asserting it never can again. Every real-world bug in the project ends life as a permanent regression test.

Regression tests of this kind are cheap to write at the moment of the fix and impossible to reconstruct later, when the investigation’s details have faded. The discipline is mechanical: before closing any user-reported bug, add a test named for the bug, seeded with the exact reported input, asserting the corrected behavior. The test suite becomes the project’s institutional memory, every scar documented, every lesson enforced.

This bug earned an additional layer of protection because one of its wrong fixes had itself been reintroduced once: a test now verifies, at the source level, that the discarded conditional-guard pattern cannot quietly return. When a bug’s history includes a regression of its own fix, the fix deserves a test and the anti-pattern deserves a tripwire. Between the two, this defect class is closed, not by vigilance, which fades, but by CI, which does not (Part 14).

Next: Part 8, Emoji, utf8mb4, and Byte-Fidelity Certification

FAQ

Why does my site name appear twice in my title tag?

Your stored SEO title already contains the brand, and your theme or SEO plugin appends it again. The duplicate check likely fails due to invisible Unicode characters, encoding differences, or case/script variants.

What are invisible Unicode characters?

Non-printing codepoints, right-to-left marks, zero-width joiners, directional embeddings, that travel with copied text. They’re invisible to humans but make identical-looking strings compare as different.

How do I detect if a title already contains my brand name?

Strip invisible characters, apply Unicode NFC normalization, and lowercase both strings with multibyte-aware functions before doing the containment check.

Should I clean brand names out of my stored SEO titles?

No, fix it at render time. Mutating stored titles corrupts user data and breaks intentional separators; a render-time guard fixes presentation without touching storage.

How do I remove hidden characters from text?

Do not remove them from stored content, normalize copies at comparison time instead. Strip invisible formatting codepoints, apply NFC normalization, and case-fold before comparing; the stored original stays untouched.

Why does my title show the brand in two different languages?

Your stored title carries the brand in one script and your platform appends it in another. A normalization-aware duplicate guard at render time detects the existing brand and suppresses the second copy.

Keep reading in this series

Keep reading

The Invisible-Character Bug: Hebrew Titles and Duplicate Brand Suffixes

How invisible Unicode characters caused duplicate brand names in Hebrew title tags — and the render-time guard that fixed it for every language.

Bernard

Building a Hebrew-First SEO Plugin (“Project Spark”) — Part 7 of 20

A client’s Hebrew pages started rendering titles like “Page Name | Brand – Brand” — the brand appended twice, sometimes in two different scripts. The root cause was a chain of reasonable-looking behaviors colliding: stored titles that already contained the brand, WordPress’s template appending it again, and invisible Unicode characters defeating the duplicate check. This post is the anatomy of that bug and the fix that holds in every language.

It is written as a detective story because that is how it unfolded: a symptom that looked impossible, three separate mechanisms hiding behind one comparison, and a fix whose final form was shaped as much by two wrong fixes as by the right one.

Key takeaway: Any “does this title already contain the brand?” check that uses plain string comparison will fail on real-world multilingual data. Correct duplicate detection requires stripping invisible characters, Unicode NFC normalization, and case folding — before comparing anything.

The report: a title that doubled itself

The report arrived with a screenshot from live search results: a page title carrying the brand name twice, joined by two different separators, in two scripts. The immediate confusion was that the code responsible looked correct. There was a stored SEO title; there was a check asking whether the stored title already contained the site name; the check said no; the platform appended the brand. Every step behaved as designed — and the output was wrong anyway.

That combination — locally correct steps, globally wrong result — is the signature of a data problem rather than a logic problem. The stored title did contain the brand. The comparison simply could not see it.

How the duplication happens

WordPress core appends the site name to document titles by default. That’s usually right. But titles imported from a previous SEO plugin — or hand-written by editors — often already include the brand, with the editor’s chosen separator. Append again and you get doubled brand chrome.

The mixed-script variant made my client’s case especially visible: the stored title carried the brand in one script, the platform appended it in another, so the doubled result displayed two different spellings of the same business — obvious to every customer, invisible to every string comparison.

The naive fix is obvious: check whether the stored title contains the site name and skip the append. And the naive fix fails in production, for three reasons:

1. Invisible characters break equality

Hebrew text pasted from chat apps and word processors carries right-to-left marks (U+200F), left-to-right marks, zero-width joiners, and directional embeddings. These characters exist for legitimate reasons — they make mixed-direction text display correctly in the tools where the text was originally written — but copy-paste transports them into places they were never meant to live.

The stored title contained the brand plus a hidden RLM; the site name didn’t. “Contains” returned false. Brand appended. Duplicate shipped. Diagnosis required inspecting the string at the codepoint level; nothing at the character-display level showed any difference at all.

2. Unicode has multiple spellings for the same text

Accented and composed characters can be encoded as one codepoint or several (base + combining marks). Visually identical, byte-different. Two independently typed copies of the same brand name can disagree at the byte level depending on the operating system, keyboard, and application that produced them. Comparisons must normalize both sides to NFC first — the canonical composed form — before any equality test means what a human thinks it means.

3. Case and script variants

Brand names live in multiple cases and sometimes multiple scripts. Comparison must be case-insensitive using multibyte-aware lowercasing — the ASCII-only variety silently no-ops on non-Latin text, which means a check that appears case-insensitive in English is case-sensitive in every other alphabet. That partial correctness is worse than a plain failure, because English-language testing will never expose it.

Reproduce it yourself in five minutes

The failure is easy to demonstrate, and doing so once permanently changes how you read string comparisons. Copy a sentence from a chat application into any tool that reveals Unicode codepoints (search for a “Unicode inspector” — several run in the browser). If the text is Hebrew or Arabic, or was ever near mixed-direction content, you will very likely find directional marks that no editor displays. Now paste the same visible sentence typed fresh from a keyboard, and compare: identical on screen, different in codepoints, and unequal in any programming language’s default comparison.

That five-minute experiment explains support tickets across the entire software industry — the record that “exists but can’t be found,” the duplicate that “was already entered,” the validation that “rejects correct input.” The characters are doing their designed job; the comparisons are simply operating below the level at which humans perceive text. Once you have seen it, you write different code.

Two wrong fixes first

The final design was the third attempt, and the two failures shaped it.

Wrong fix one: clean the data. The instinct was to strip brand suffixes during import, so stored titles would never contain the brand and the append could proceed safely. This violates the data-faithfulness contract from Part 6 — it mutates user content based on a heuristic — and it broke separators in the process, replacing editors’ deliberate choices with platform defaults. Duplication is a presentation problem; solving it by rewriting data destroys information that can never be recovered.

Wrong fix two: guard conditionally. The next version ran the duplicate check only when a related plugin feature was enabled, on the assumption that duplication could only arise through that feature. Bug reports disproved the assumption within weeks: duplicated brands arrived through imports, through hand-typed titles, through content edited long before the plugin existed. The lesson: a guard that protects against a data condition must not be gated behind a feature toggle, because the data condition can arrive from anywhere.

The fix: an unconditional render-time guard

The final design puts one guard at the single point where WordPress composes the document title. It normalizes both the stored title and site name (strip invisibles → NFC → multibyte case-fold) and, when the brand is already present, tells WordPress not to append its own copy. No stored data is touched — consistent with the store-verbatim-render-smart principle from Part 2.

Two design details worth adopting:

  • Unconditional. The guard always runs, for every title, in every language. It is cheap — a normalization and a containment check at render time — and the alternative, as the second wrong fix demonstrated, is a guard that misses exactly the cases nobody predicted.
  • Render-time, not import-time. Presentation problems get presentation-layer fixes. A render-time guard corrects every page instantly when it ships, requires no data migration, and is fully reversible — three properties no storage-side “cleanup” can offer.

The normalization pipeline inside the guard is the reusable artifact: strip invisible formatting characters, normalize to NFC, case-fold with multibyte-aware functions, then compare. That sequence belongs in every equality or containment check that touches user-entered multilingual text — deduplication, search, validation, anywhere strings meet human input.

Where else this bug family lives

The doubled title is one member of a large family, and recognizing the family is worth more than fixing the instance. Any system that compares human-entered text against human-entered text carries the same exposure. Duplicate detection in content audits: two “identical” descriptions that fail to deduplicate because one carries a directional mark. Search within an admin interface: a query that cannot find a record whose stored form differs only in normalization. Keyword matching in internal-linking tools: an anchor phrase that never matches because the stored keyword was pasted from a chat application. Validation rules: a “this field must equal that field” check that fails for reasons no user can see or correct.

Project Spark applies the same normalization pipeline at every one of these comparison points, and the test corpus for each includes strings salted with invisible characters — because a defense that exists in one place and not another is a defense the next feature will forget. The broader habit transfers to any codebase: when a bug reveals a class (here: naive comparison of multilingual text), sweep every comparison site in the system before closing the ticket, and encode the sweep’s findings as tests. The alternative is fixing the same bug annually under different names.

Regression-proofing

The exact user-reported Hebrew strings (anonymized in tests) are pinned in the automated suite: the precise input that once produced a doubled title now has a named test asserting it never can again. Every real-world bug in the project ends life as a permanent regression test.

Regression tests of this kind are cheap to write at the moment of the fix and impossible to reconstruct later, when the investigation’s details have faded. The discipline is mechanical: before closing any user-reported bug, add a test named for the bug, seeded with the exact reported input, asserting the corrected behavior. The test suite becomes the project’s institutional memory — every scar documented, every lesson enforced.

This bug earned an additional layer of protection because one of its wrong fixes had itself been reintroduced once: a test now verifies, at the source level, that the discarded conditional-guard pattern cannot quietly return. When a bug’s history includes a regression of its own fix, the fix deserves a test and the anti-pattern deserves a tripwire. Between the two, this defect class is closed — not by vigilance, which fades, but by CI, which does not (Part 14).

FAQ

Why does my site name appear twice in my title tag?

Your stored SEO title already contains the brand, and your theme or SEO plugin appends it again. The duplicate check likely fails due to invisible Unicode characters, encoding differences, or case/script variants.

What are invisible Unicode characters?

Non-printing codepoints — right-to-left marks, zero-width joiners, directional embeddings — that travel with copied text. They’re invisible to humans but make identical-looking strings compare as different.

How do I detect if a title already contains my brand name?

Strip invisible characters, apply Unicode NFC normalization, and lowercase both strings with multibyte-aware functions before doing the containment check.

Should I clean brand names out of my stored SEO titles?

No — fix it at render time. Mutating stored titles corrupts user data and breaks intentional separators; a render-time guard fixes presentation without touching storage.

How do I remove hidden characters from text?

Do not remove them from stored content — normalize copies at comparison time instead. Strip invisible formatting codepoints, apply NFC normalization, and case-fold before comparing; the stored original stays untouched.

Why does my title show the brand in two different languages?

Your stored title carries the brand in one script and your platform appends it in another. A normalization-aware duplicate guard at render time detects the existing brand and suppresses the second copy.


Previous: Part 6: Migrating From Yoast Without Corrupting a Byte
Next: Part 8: Emoji, utf8mb4 & Byte-Fidelity in WordPress SEO

Keep reading

Newsletter

Get the next playbook in your inbox.

One short, no-fluff email per fortnight.

Ready when you are

Let's map the next 90 days of growth.

Book a no-pressure call. We'll review your funnel, share quick wins, and outline what compounding growth could look like for your business.

Get a free audit

A note on cookies

We use cookies to measure how this site performs and to make it better. Analytics cookies only run if you accept.

Read our privacy policy