Dev.to WebDev 🛠 Dev 👁 0 📖 6 min read

15 pages declared lang="en". 75-98% of their characters were Han.

Fifteen pages told the browser they were English. Between 75% and 98% of their characters were Han. I only found it because I stopped checking meta tags and started counting characters. The pages had titles, description

Fifteen pages told the browser they were English. Between 75% and 98% of their characters were Han.

I only found it because I stopped checking meta tags and started counting characters. The pages had titles, descriptions, canonicals, one h1 each, valid JSON-LD — everything the usual audit looks at. The lang attribute was the one field nobody had ever verified against the body text, and on four domains it was wrong on every page I sampled.

What I sampled

I started with 40 domains. Twelve of their root sitemaps listed nothing but child sitemaps on subdomains, so there was no page URL to sample — that is a different bug and a different post. The remaining 28 domains gave me up to four page URLs each, taken from their own sitemaps and filtered to URLs on the site's own host, excluding the homepage and any .xml entries. No homepage, no sitemap, no XML: real HTML pages only.

That produced 106 pages. I parsed them once for structure and re-fetched 104 of them for the language pass — two timed out on the second pass.

The measurement is not clever. Strip script, style, and noscript, count Han characters, count Latin words, and take the ratio:

import re
from html.parser import HTMLParser

HAN = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff]")

class Text(HTMLParser):
    def __init__(self):
        super().__init__()
        self.skip = 0
        self.buf = []

    def handle_starttag(self, tag, attrs):
        if tag in ("script", "style", "noscript", "template"):
            self.skip += 1

    def handle_endtag(self, tag):
        if tag in ("script", "style", "noscript", "template") and self.skip:
            self.skip -= 1

    def handle_data(self, data):
        if not self.skip and data.strip():
            self.buf.append(data.strip())

def language_fingerprint(html):
    p = Text()
    p.feed(html)
    text = " ".join(p.buf)
    han = len(HAN.findall(text))
    latin = len(re.findall(r"[A-Za-z]+", text))
    total = han + latin
    return {
        "han": han,
        "latin": latin,
        "han_share": round(han / total, 3) if total else None,
        "declared": (re.search(r'<html[^>]+lang=["\']([^"\']+)', html) or [None, ""])[1],
    }

A 10% threshold separates the two populations cleanly enough that the exact cutoff does not matter: the mislabeled pages sat at 75% and above, and the English pages sat at 0%. There is no ambiguous middle in this sample.

The split

Declared lang Pages Actually Chinese (>10% Han)
zh-CN 48 48
en 56 15

Fifteen of 56 English-declared pages, 26.8%, were Chinese. Put the other way: of the 63 pages in this sample whose body text is Chinese, 23.8% announced themselves as English.

The distribution is not random. All fifteen sat on four domains, and on those domains every page I sampled was wrong — 4 of 4, 4 of 4, 4 of 4, 3 of 3. That is the signature of a constant, not a content mistake. Somebody wrote <html lang="en"> into the root layout of a site whose content pipeline emits Chinese, and nothing downstream ever compared the two.

You can see it in the wild: an FAQ page that declares en while 96.7% of its characters are Han, and a category page with the same declaration and 94.3% Chinese text. Both render fine, both pass a head-tag audit, both would fail this one.

What a wrong lang actually breaks

It does not break rendering, which is why it survives. It breaks the parts of the stack that read the attribute instead of the pixels:

  • Screen readers choose a voice from lang. An English voice reading Han characters either spells them out or skips them, depending on the engine. This is the one failure mode with a real user on the other end.
  • Translation prompts. Chrome keys its offer to translate off the declared language first. A page that says en rarely gets the offer, even when every word is Chinese.
  • lang-aware CSS. :lang(zh) rules never match, and quote characters, hyphenation, and text-transform follow English rules on Chinese text.
  • Font fallback. The browser resolves fonts per script, but the declared language steers the choice within a script — and it decides whether CJK glyphs come from the page's intended font stack or from whatever the OS falls back to.
  • Search. Google has said for years that it infers page language primarily from the visible content, so I am not going to claim a ranking delta from 104 pages. What I can say is that the stack had no second opinion to offer: 28 of 100 pages carried any hreflang alternate, zero of 100 sent a Content-Language header, and all fifteen mislabeled pages carried zero hreflang. Nothing in the markup disagreed with the wrong tag.

The control case is boring, which is the point: the same FAQ template on another domain in the same network declares en and is 0% Han. Same generator, correct locale, because that site's config was set correctly.

The check, as a CI gate

I turned this into a script that crawls a sample and exits non-zero. It is thirty lines and it would have caught all fifteen:

import sys, urllib.request
from language_fingerprint import language_fingerprint  # the function above

URLS = [...]  # one page per template, not every page

def check(url, expect):
    req = urllib.request.Request(url, headers={"User-Agent": "lang-guard/1.0"})
    with urllib.request.urlopen(req, timeout=20) as r:
        fp = language_fingerprint(r.read().decode("utf-8", "replace"))
    declared = fp["declared"].split("-")[0].lower()
    share = fp["han_share"] or 0
    implied = "zh" if share > 0.10 else "en"
    ok = declared == implied
    print(f"{'ok  ' if ok else 'FAIL'} {url} declared={declared} "
          f"implied={implied} han={share}")
    return ok

failed = [u for u, e in URLS if not check(u, e)]
sys.exit(1 if failed else 0)

Run it on one representative page per template — not per URL. The failure I found was template-level, so a four-URL sample per site was enough to catch it on four sites out of 28.

The fix is one line, the source of truth is the hard part

<html lang="en"> in a root layout is a hardcoded default pretending to be a decision. The page's language belongs to the content, or to the locale segment of the route, and the layout should read it:

// __root.tsx — one locale, resolved per request, never a literal
export function Root({ locale, children }: { locale: "en" | "zh"; children: ReactNode }) {
  return (
    <html lang={locale}>
      <body>{children}</body>
    </html>
  );
}

If articles carry their own language column, use that instead and let the route default be a fallback that logs when it fires. A fallback that fires silently is how you end up with a site whose Chinese content announces itself as English for as long as nobody counts characters.

Two more things the same crawl turned up

2,777 leaked source annotations. Seven pages across two domains shipped attributes like data-tsd-source="/src/components/Layout.tsx:10:9" — 2,777 of them in total. That is TanStack Start's dev-mode source annotation surviving into production HTML. It costs users nothing to download and it hands anyone who views source your component tree, file names, and line numbers. If you see it, your production build is running with the dev transform on.

Zero Content-Language headers. Not one of the 100 pages that answered sent the header. That is normal for most static stacks and not a bug on its own — but combined with a wrong lang, it means the response carried no language signal that was correct.

What this does not prove

Four pages per domain, one crawl, one afternoon. The Han-ratio heuristic ignores mixed-script pages, and I dropped four URLs that failed on re-fetch rather than retrying them. I have no before-and-after data on indexing or rankings, because I changed nothing — this was a measurement pass, and the fix is not shipped yet.

What it does establish is narrower and more useful: a head-tag audit says nothing about whether your lang attribute is true, and on a 28-site sample, 15 pages were lying about it in the same direction, from the same root layout, on four sites that had never checked.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.