Translations and hreflang
How the locale axis is decided, why the sitemap matters more than the links, what reciprocity means, and how to declare a gap you meant to leave.
This is the check goflag was built for, and the one where it was wrong first. See the failure that started it.
The matrix
goflag builds a route × locale matrix. A route is a URL with its locale
segment removed: /fr/pricing and /en/pricing are one route, /pricing, in two
locales. A cell is filled when a page for that route and locale was found. An
empty cell is a hole: a translation the site serves in one language and not
in another.
Where the locale axis comes from
The axis is the list of locales the site is considered to serve. Everything else depends on it, so it is decided explicitly, in this order:
-
--locales: authoritative. It overrides both of the following, and it is the only way to make a locale the site does not serve yet show up as missing.goflag https://example.com --locales "en,fr,es,pt-br" -
The sitemap. Locale prefixes seen in
sitemap.xml, including sitemap indexes. This is the reliable automatic source, because a sitemap lists pages whether or not anything links to them. -
Nothing. If there is no sitemap and no
--locales, goflag does not guess. It reports the prefixes it saw during the crawl, with the evidence for each, and turns the translation checks off.
That third case used to be a guess, and the guess was wrong in a way that mattered. See below.
Reciprocity
A filled matrix is not enough: the pages have to point at each other. These are
reported under missingTranslations.reciprocity, with a code rather than a rule
id, because they are computed cross-page rather than from the per-page registry:
| Code | What it means |
|---|---|
missing-back-link | A declares an alternate to B, and B does not link back. hreflang is only honoured when reciprocal; a one-way declaration is discarded. |
x-default-missing | The cluster advertises several locales but no hreflang="x-default", which is where a visitor matching none of your languages is sent. |
locale.invalid | The tag is not valid BCP 47. pt_BR with an underscore instead of a hyphen voids the whole alternate: it is ignored, not corrected. |
self-mismatch | A page's self-referential alternate does not point at its own canonical URL, which makes it a member of somebody else's cluster. |
Declaring a gap you meant to leave
Not every hole is a defect. A legal page that exists only in French, a blog post never translated: pass the route and it stops being reported.
goflag https://example.com --ignore-holes "/legal/**" --ignore-holes "/blog/2019-*"The glob matches the locale-free route, not the URL: /legal/** covers
/fr/legal/privacy and /en/legal/privacy in one line. It is repeatable, and the
suppressed count is still reported under diagnostics.ignoredHoles, so a
suppression cannot hide how much it is hiding.
The two site-wide rules
Beyond the matrix, two rules judge the site as a whole:
hreflang.missing: the site serves several locales and a page declares no alternates at all. Without them, an engine cannot tell four translations from four competing pages.hreflang.sitemap-mismatch: the<head>and the sitemap disagree about which locales a route has. They are two declarations of one intent, produced by different code paths, so they drift.
The bug that started this
The first version of goflag reported zero missing translations on a four-locale
site that carried not a single hreflang tag. The locale axis was derived from
the alternates the pages declared, so a silent site had one column and every
check passed.
goflag was detecting the absence of hreflang by reading the hreflang.
Capturing the first baselines surfaced the same class of mistake twice more. The
worst read the path segment /cv as Chuvash (a real ISO 639-1 code) and
invented 31 translation holes that never existed.
Both are fixed, and together they are why the axis is now either declared by you, taken from your sitemap, or not assumed at all.