Ship-aware language picker
A ship-aware language picker offers your users only the locales that are ready,
and marks the ones that are AI translation. kapi writes each locale's standing
to a ship.json manifest, and @neokapi/i18n-react/ship turns that manifest
into a picker model.
The gates in the recipe decide each locale's state, and the picker reads the manifest kapi writes.
Two gates decide what ships
A project's maintainers decide which translated versions to offer its users.
They declare that decision as two gates in kapi.yaml, and kapi evaluates
both the same way against the target status ladder
(draft → translated → approved):
- The ship gate: the bar to go live. A locale that clears it ships
translated: translated with its checks green, and safe to offer. It is theship_gate/ship_gatesconfiguration. - The approval gate: the bar to ship approved, meaning a person approved
the content. It is the
established_gate/established_gatesconfiguration. The recipe andship.jsonname the approved rung and stateestablished.
A locale that ships translated is AI work, and the picker marks it. A project
that delivers only approved content says so in its ship gate
(ship_gate: { established: 100 }).
Declaring the approval gate
The approval gate uses the same three additive forms and the same
precedence as the ship gate, and resolves a gate: name against the shared
gates: registry:
# kapi.yaml
ship_gate: { translated: 100 } # go live once fully translated
established_gate: { established: 100 } # ship approved once a person approved every translation
A rule list narrows the bar per collection or locale, most-specific rule wins:
established_gates:
- when: { locales: [ja] }
gate: { established: 100 } # Japanese needs a person on every translation
- gate: { established: 80 }
A project with no approval gate has no locale in the established
state: every locale that ships reads translated, marked AI.
A locale that no ship_gate or ship_gates rule matches has no bar to clear,
so kapi reports it as not gated rather than shippable. Nothing withholds it
on coverage, so the picker offers it by default. Stale wording, a translation a
reviewer turned down, a failing check, or terms that were not checked withhold a
locale whether or not a gate matches it.
Emitting the manifest
kapi status --ship writes each locale's standing as a small manifest that a
language picker reads. Write it into your app's static assets as part of the
build:
kapi status --ship --emit public/ship.json
Without --emit, the manifest goes to stdout, so a build step can redirect it:
kapi status --ship > public/ship.json
The file is keyed by locale, each entry carrying whether the locale ships and its ship state:
{
"fr": { "shippable": true, "state": "established" },
"de": { "shippable": true, "state": "translated" },
"nl": { "shippable": true, "state": "translated", "not_governed": ["terms"] },
"sv": { "shippable": true, "state": "not_gated" },
"ja": { "shippable": false, "state": "withheld" }
}
Here French ships approved (no badge), German ships translated (flagged AI), Dutch ships the same way in a language no terms govern, Swedish has no gate and is offered as not gated, and Japanese is withheld.
state takes one of four values:
state | Meaning | shippable |
|---|---|---|
established | An approval gate matches the locale and the locale clears it: a person approved the content. | true |
translated | A ship gate matches the locale and the locale clears it, and no approval gate is met: the locale ships as AI translation. | true |
withheld | The locale does not ship: it is short of its gate, or stale, rejected or failing content, or unchecked terms, hold it back. | false |
not_gated | No gate matches the locale, and nothing withholds it. | true |
A locale spread over several collections takes the weakest state among them, in
the order withheld, not_gated, translated, established. shippable is true
for every state but withheld, so a picker that reads only shippable offers a
not-gated locale. state tells a cleared gate from no gate.
An entry carries not_governed when a dimension governs nothing in that
language. terms means that none of the terms bound where the language's
content sits, under the project defaults or on a profile, has a term for it. The
picker ignores the field; it tells a build step or a person that no terms
govern the language.
For dashboards, the kapi status --json report carries shippable and the
state, as shipState, per collection and locale, with the full coverage
percentages.
A hosted feed instead of a built file
The loader takes a URL, so it can read the manifest from a hosted feed as well as from a built file. A hosted feed is a server that serves the per-locale manifest live at a public URL. You drop the build step, and the picker reads the current standing on each load.
The feed returns the file's shape: an object keyed by locale, each value
{ shippable, state }, with not_governed where it applies. A hosted feed is
read-only and needs no auth, since a public picker fetches it directly. It
should send an ETag and a short Cache-Control: public, max-age=…, so a
picker or a CDN can revalidate cheaply with a 304.
Point the loader at the URL; nothing else changes:
const status = await loadShipStatus("https://example.com/ship.json");
const model = languagePickerModel(status, ["en", "fr", "de", "ja"]);
The same languagePickerModel transform and the same useShipStatus hook (whose
second argument is the manifest URL) drive the picker for a built file and a
hosted feed alike.
Driving the picker
@neokapi/i18n-react/ship provides a dependency-free loader and a headless
transform. If the manifest is missing or malformed, the loader resolves to an
empty object, and the picker shows every locale without a badge.
Pass locale codes. The label for each is the locale's endonym (the language
named in its own language), from Intl.DisplayNames, so you keep no per-locale
label table:
import { loadShipStatus, languagePickerModel } from "@neokapi/i18n-react/ship";
const status = await loadShipStatus(); // defaults to /ship.json
const model = languagePickerModel(status, ["en", "fr", "de", "ja"]);
// → [{ locale: "fr", label: "Français", shippable: true, badge: 'ai' | null, state: "translated" }, …]
The label is resolved in this order: an explicit label on a LocaleInput, then
an entry in the labels override map, then the Intl.DisplayNames endonym, then
the raw code. An explicit label replaces the derived one where you want a
different form, and the override map names locales that Intl cannot. A
pseudo-locale such as qps has no standard name, so give it one:
const model = languagePickerModel(status, ["fr", "de", "qps"], {
labels: { qps: "Pseudo English" },
});
// fr → "Français" (derived), de → "Deutsch" (derived), qps → "Pseudo English" (override)
The signature is
languagePickerModel(status, locales, options?), where options is
{ labels?: Record<string, string>; includeNotGated?: boolean }.
The first letter of a derived endonym is capitalized for a menu-style label
(français → Français), so lowercase endonyms read consistently alongside the
ones that are already capitalized (Deutsch); scripts without case (日本語) are
left unchanged. If Intl.DisplayNames is unavailable or has no name for a code,
the label falls back to the override map and then to the raw code; it never
throws.
languagePickerModel returns the locales whose entry is shippable, which
includes a not-gated locale. Pass includeNotGated: false to offer only the
locales that clear a gate. Each entry carries the manifest's state and a
badge: 'ai' when the locale ships but is not established, and null when
it is. 'ai' is the only badge this layer emits; an approved locale has
no badge. A React binding wraps the same two functions and takes the same options
as a third argument:
import { useShipStatus } from "@neokapi/i18n-react/ship/react";
function LanguagePicker({ locales }) {
// loads /ship.json, derives labels, returns the model
const options = useShipStatus(locales, undefined, { labels: { qps: "Pseudo English" } });
return (
<ul>
{options.map(({ locale, label, badge }) => (
<li key={locale}>
{label}
{badge === "ai" && <span className="badge-ai">AI</span>}
</li>
))}
</ul>
);
}
You render the picker yourself: style the entries and the AI badge to match
your design. Development-only locales (pseudo-translation such as qps)
are not part of the ship manifest; name them through the labels override map
if you list them in the picker, or surface them separately in your own dev
tooling.
Compatibility
The approval gate is additive. A recipe that declares none has every
shipping locale read translated. The picker helper degrades safely when
ship.json is absent, and reads an entry with no state by its shippable
flag alone, so a project can adopt the manifest and the picker independently.
A recipe that still carries verified_gate fails to load and names
established_gate.
Related
- Ship gates and CI: declaring gates and failing a release on them.
- Runtime vs. inline mode: loading the locales the picker offers.