Gå til hovedinnhold

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.

kapi.yamlship + approval gateskapi status --shipship.jsonlocale → statelanguagePickerModelLanguage pickerAI badge

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 the ship_gate / ship_gates configuration.
  • The approval gate: the bar to ship approved, meaning a person approved the content. It is the established_gate / established_gates configuration. The recipe and ship.json name the approved rung and state established.

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 }
With no approval gate, nothing ships approved

A project with no approval gate has no locale in the established state: every locale that ships reads translated, marked AI.

A locale no ship gate matches is not gated

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:

stateMeaningshippable
establishedAn approval gate matches the locale and the locale clears it: a person approved the content.true
translatedA ship gate matches the locale and the locale clears it, and no approval gate is met: the locale ships as AI translation.true
withheldThe locale does not ship: it is short of its gate, or stale, rejected or failing content, or unchecked terms, hold it back.false
not_gatedNo 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.