Šķîþ ţö ḿàîñ çöñţéñţ

Ļîñţîñĝ

neokapi-î18ñ'š ƃüîļđ-ţîḿé ţŕàñšƒöŕḿ çàţçĥéš à ļöţ, ƃüţ šöḿé àüţĥöŕîñĝ ḿîšţàķéš öñļý šĥöŵ üþ àƒţéŕ éẋţŕàçţîöñ (à t(variable) ţĥàţ çàñ'ţ ƃé éẋţŕàçţéđ, à ļàƃéļ šţŕîñĝ ĥîđđéñ îñ à đàţà àŕŕàý ţĥàţ ţĥé éẋţŕàçţöŕ ñéṽéŕ ŵàļķš, à ţéŕñàŕý ţĥàţ šḿüĝĝļéš ţŵö ļîţéŕàļš þàšţ ţĥé ĴŠẊ ŵàļķéŕ). @neokapi/i18n-react-lint ĝîṽéš ýöü éđîţöŕ šǫüîĝĝļîéš ƒöŕ ţĥöšé çàšéš.

Ţĥé šàḿé ŕüļé öƃĵéçţš ŵöŕķ üñđéŕ ÉŠĻîñţ àñđ öẋļîñţ: öẋļîñţ'š þļüĝîñ ÀÞÎ îš ÉŠĻîñţ ṽ9 çöḿþàţîƃļé, šö ýöü îñšţàļļ öñé þļüĝîñ àñđ ŵîŕé îţ îñţö ŵĥîçĥéṽéŕ ļîñţéŕ ýöü àļŕéàđý üšé. Öẋļîñţ îš ŕéçöḿḿéñđéđ ƒöŕ šþééđ (ţýþîçàļļý 100–200ḿš öñ à ƒéŵ ĥüñđŕéđ ƒîļéš).

Ţĥé ţĥŕéé ļàýéŕš

ĻàýéŕŴĥéñ îţ ŕüñšŴĥàţ çàţçĥéšĤöŵ ļöüđ
Ļîñţ ŕüļéšéđîţöŕ / oxlint / eslintšîñĝļé-ƒîļé àüţĥöŕîñĝ ḿîšţàķéš (üñéẋţŕàçţàƃļé t() çàļļš, çöñçàţ îñ ţŕàñšļàţàƃļé àţţŕš, ţéŕñàŕîéš šḿüĝĝļîñĝ ļîţéŕàļš þàšţ éẋţŕàçţîöñ)þéŕ-ŕüļé šéṽéŕîţý îñ ýöüŕ çöñƒîĝ
Þļüĝîñ ŵàŕñîñĝšƃüîļđ-ţîḿé ţŕàñšƒöŕḿçŕöšš-çüţţîñĝ îššüéš ţĥàţ ñééđ çöñƒîĝ çöñţéẋţ (üñḿàþþéđ çöḿþöñéñţš → çöḿþöñéñţḾàþ, ţéŕñàŕý àţţŕš ţĥé éẋţŕàçţöŕ çàñ'ţ ŕéšöļṽé)console.warn ƃý đéƒàüļţ
ÉñƒöŕçéḿéñţÇÎƃöţĥ öƒ ţĥé àƃöṽé, þŕöḿöţéđ ţö éŕŕöŕš--strict öñ ţĥé éẋţŕàçţ ÇĻÎ öŕ warningsAsErrors: true îñ þļüĝîñ çöñƒîĝ

Ķééþ ţĥé ļöüđéšţ ļàýéŕ (éñƒöŕçéḿéñţ) öƒƒ îñ đàý-ţö-đàý àüţĥöŕîñĝ, àñđ ţüŕñ îţ öñ îñ ÇÎ öñçé ţĥé çöđéƃàšé îš çļéàñ.

Îñšţàļļ

npm install -D @neokapi/i18n-react-lint

Öẋļîñţ

Àđđ ţö .oxlintrc.json:

{
"jsPlugins": ["@neokapi/i18n-react-lint/oxlint"],
"rules": {
"neokapi-i18n/t-literal-first-arg": "error",
"neokapi-i18n/t-no-concat": "error",
"neokapi-i18n/no-concat-in-translatable-attr": "error",
"neokapi-i18n/no-ternary-in-translatable-attr": "error",
"neokapi-i18n/no-ternary-literals-in-jsx-child": "error",
"neokapi-i18n/no-string-literal-jsx-expr": "warn",
"neokapi-i18n/prefer-t-for-label-expr": "warn"
},
"overrides": [
{
"files": ["src/stories/**"],
"rules": {
"neokapi-i18n/no-ternary-literals-in-jsx-child": "off",
"neokapi-i18n/prefer-t-for-label-expr": "off"
}
}
]
}

Ţĥé overrides ƃļöçķ đîšàƃļéš ţĥé ţŵö ĥîĝĥéŕ-ƑÞ ŕüļéš ƒöŕ Šţöŕýƃööķ ƒîẋţüŕé ƒîļéš, ŵĥéŕé đéḿö šţŕîñĝš đöñ'ţ ŵàŕŕàñţ ţĥé šàḿé ŕîĝöŕ.

ÉŠĻîñţ (ƒļàţ çöñƒîĝ)

eslint.config.js
import { recommended } from "@neokapi/i18n-react-lint/eslint";

export default [
{
files: ["**/*.{ts,tsx,js,jsx}"],
languageOptions: {
ecmaVersion: 2023,
sourceType: "module",
parserOptions: { ecmaFeatures: { jsx: true } },
},
},
recommended,
];

Ţĥé šĥàŕéàƃļé çöñƒîĝš àŕé recommended (šàƒé đéƒàüļţš: ţĥé ƒîṽé çöŕé ŕüļéš àţ error, ţĥé ţŵö ļàƃéļ ŕüļéš àţ warn, prefer-t-for-label-props öƒƒ) àñđ recommendedStrict (éṽéŕýţĥîñĝ àţ error, îñçļüđîñĝ prefer-t-for-label-props àñđ prefer-t-for-label-expr).

Ţĥé Ŵ3Ç translate="no" éšçàþé ĥàţçĥ

Àļļ ŕüļéš îñ ţĥîš þàçķàĝé ŕéšþéçţ translate="no" öñ ţĥé éļéḿéñţ îţšéļƒ öŕ àñý ĴŠẊ àñçéšţöŕ. Ţĥé neokapi-î18ñ éẋţŕàçţöŕ àļŕéàđý ĥöñöüŕš îţ; ţĥé ļîñţ ŕüļéš ḿàţçĥ ţĥöšé šéḿàñţîçš.

// Rule fires: {meta.label} looks like user-visible copy.
<h1>{meta.label}</h1>

// Rule silent: author explicitly marked the subtree as non-translatable.
<h1 translate="no">{meta.label}</h1>

// Rule silent: an ancestor opted out, so the whole subtree is quiet.
<section translate="no">
<div>
<h1>{meta.label}</h1>
</div>
</section>

Üšé îţ ƒöŕ đàţà ţĥàţ'š ļéĝîţîḿàţéļý đýñàḿîç (ƃàçķéñđ îđéñţîƒîéŕš, ƒîļé þàţĥš, ṽéŕšîöñ šţŕîñĝš, üšéŕ-þŕöṽîđéđ ñàḿéš) ŵîţĥöüţ þöļļüţîñĝ ţĥé ļîñţ çöñƒîĝ ŵîţĥ þéŕ-ļîñé đîšàƃļéš.

Ŕüļéš

t-literal-first-arg

Ƒļàĝš t(variable) / t(getLabel()) / t(cond ? 'A' : 'B'). Ţĥé éẋţŕàçţöŕ ŕéàđš ţĥé ƒîŕšţ àŕĝüḿéñţ öƒ t() šţàţîçàļļý àţ ƃüîļđ ţîḿé; àñýţĥîñĝ ţĥàţ îšñ'ţ à ļîţéŕàļ þŕöđüçéš ñöţĥîñĝ ţö ţŕàñšļàţé.

// ✓ fine
t("Sign in");
t("Sign in", "Button label"); // with context

// ✗ not extractable
t(label);
t(labels[key]);
t(ok ? "Save" : "Cancel");

t-no-concat

Ƒļàĝš t('Hello ' + name) àñđ t(`Hello ${name}`); ñéîţĥéŕ éẋţŕàçţš ƃéçàüšé ţĥé ƒüļļ šţŕîñĝ îšñ'ţ ṽîšîƃļé àţ ƃüîļđ ţîḿé. Üšé à þļàçéĥöļđéŕ þàţţéŕñ îñšţéàđ.

// ✗ broken
t("Welcome " + user.name);
t(`You have ${count} messages`);

// ✓ extractable, rendered via runtime substitution
t("Welcome {name}", { name: user.name });
// or use <Plural>/<Select> for pluralisation

no-concat-in-translatable-attr

Àñý àţţŕîƃüţé îñ neokapi-î18ñ'š ţŕàñšļàţàƃļé-àţţŕîƃüţé šéţ (alt, title, placeholder, aria-label, label, description, helpText, …) ḿüšţ ƃé à šţŕîñĝ ļîţéŕàļ öŕ à ļîţéŕàļ ŵîţĥ þļàçéĥöļđéŕš; à ŕüñţîḿé çöñçàţ îš üñéẋţŕàçţàƃļé.

// ✗ alt won't extract
<img alt={'Logo ' + brand} />

// ✓ if you need dynamic parts, compute via t() and pass the result
<img alt={t('Logo for {brand}', { brand })} />

no-ternary-in-translatable-attr

Šîƃļîñĝ öƒ no-concat-in-translatable-attr. Ƒļàĝš ţŕàñšļàţàƃļé àţţŕîƃüţéš ŵĥöšé ṽàļüé îš à ţéŕñàŕý ŵîţĥ àţ ļéàšţ öñé ñöñ-šţŕîñĝ-ļîţéŕàļ ƃŕàñçĥ. Ţĥé àļļ-šţŕîñĝ-ļîţéŕàļ çàšé (title={cond ? "A" : "B"}) îš éẋţŕàçţéđ ƃý ţĥé neokapi-î18ñ ŵàļķéŕ àš ţŵö ƃļöçķš (ñö ŵàŕñîñĝ). Ţĥé ḿîẋéđ çàšé îš üñéẋţŕàçţàƃļé.

// ✓ both branches are string literals; the extractor handles them.
<PageHeader title={isProjectMode ? "Project Flows" : "Flows"} />

// ✓ both branches are t() calls; the t-call walker handles them.
<Input placeholder={disabled ? t("Off") : t("On")} />

// ✗ one literal, one computed: the computed branch silently bypasses translation.
<Input placeholder={disabled ? getLabel() : "Type here…"} />

Ƒîẋ ƃý ŵŕàþþîñĝ ţĥé çöḿþüţéđ ƃŕàñçĥ ŵîţĥ t() ţöö, öŕ ƃý ļîƒţîñĝ ţĥé ļöĝîç šö ƃöţĥ ƃŕàñçĥéš ŕéšöļṽé ţö šţŕîñĝ ļîţéŕàļš.

no-ternary-literals-in-jsx-child

Çàţçĥéš à ţéḿþļàţé ļîţéŕàļ îñ ţĥé ƃŕàñçĥ öƒ à ĴŠẊ-çĥîļđŕéñ ţéŕñàŕý:

// ✗ the words inside the template never extract
<span>{count > 0 ? `Loading ${count}...` : "Idle"}</span>

À ţéḿþļàţé ļîţéŕàļ îš öñé éẋþŕéššîöñ, šö éẋţŕàçţîöñ ŕéàđš îţ ŵĥöļé àñđ ţĥé ŵöŕđš îñ îţ šţàý Éñĝļîšĥ.

Ƒîẋ ŵîţĥ t(), þàššîñĝ ţĥé îñţéŕþöļàţîöñ àš à þàŕàḿéţéŕ:

// ✓ the t() call extracts as its own block, and the runtime substitutes count
<span>{count > 0 ? t("Loading {count}...", { count }) : "Idle"}</span>

Ṽàŕîàñţš ţĥé ŕüļé ĥàñđļéš çļéàñļý:

  • À ţéḿþļàţé ļîţéŕàļ ŵîţĥ àļþĥàƃéţîç ţéẋţ (`Loading ${n}...`) → ƒļàĝĝéđ, îñ éîţĥéŕ ƃŕàñçĥ.
  • Ƒöŕḿàţ-öñļý ţéḿþļàţéš ŵîţĥ ñö àļþĥàƃéţîç ǫüàšî (`${pct}%`, `v${version}`) → ñöţ ƒļàĝĝéđ (çöđé-ļéṽéļ ƒöŕḿàţţîñĝ, ñöţ ÜÎ çöþý).
  • À þļàîñ šţŕîñĝ ļîţéŕàļ ("Save") → ñöţ ƒļàĝĝéđ. Îţ éẋţŕàçţš àš îţš öŵñ ƃļöçķ, ķéýéđ ƃý ţĥé ƃŕàñçĥ'š šļöţ; šéé Ļîţéŕàļš îñ à çöñđîţîöñàļ'š ƃŕàñçĥéš.
  • À t() çàļļ, àñ éļéḿéñţ öŕ à çöḿþüţéđ ṽàļüé → ñöţ ƒļàĝĝéđ.

no-string-literal-jsx-expr

<p>{'Hello'}</p>: à ƃàŕé šţŕîñĝ ļîţéŕàļ ŵŕàþþéđ îñ àñ éẋþŕéššîöñ çöñţàîñéŕ. Îţ ļööķš éẋţŕàçţàƃļé, ƃüţ ţĥé ţŕàñšƒöŕḿ ŵàļķš ĴŠẊ ţéẋţ ñöđéš öñļý àñđ šķîþš éẋþŕéššîöñ çöñţàîñéŕš ţĥàţ ĥàþþéñ ţö ĥöļđ à šţŕîñĝ. Àüţö-ƒîẋéš ţö <p>Hello</p>.

prefer-t-for-label-expr

Ţĥé ŕéñđéŕ-šîđé çöḿþàñîöñ ţö prefer-t-for-label-props ƃéļöŵ. Ƒļàĝš {obj.label} / {item.title} / {entry.caption} ŕéñđéŕéđ àš ĴŠẊ ţéẋţ:

// ✗ `meta.label` looks user-visible; the extractor can't see the string
// it will resolve to at runtime.
<h1>{meta.label}</h1>;

// ✓ wrap the source data so the literal is visible to extraction
const categoryMeta = {
utility: { label: t("Utility") },
// …
};

Öñļý ƒîŕéš öñ à ñàŕŕöŵ šéţ öƒ þŕöþéŕţý ñàḿéš ţĥàţ àļḿöšţ àļŵàýš ñàḿé üšéŕ-ṽîšîƃļé çöþý: label, title, heading, caption, subtitle, tooltip, placeholder, summary. Đéļîƃéŕàţéļý éẋçļüđéš .name, .description, .text, .message: ţĥöšé öṽéŕŵĥéļḿîñĝļý ñàḿé ƃàçķéñđ / ŕüñţîḿé đàţà îñ ŕéàļ Ŕéàçţ àþþš àñđ ŵöüļđ çŕéàţé ţöö ḿüçĥ ñöîšé.

Çüšţöḿîšé ṽîà ţĥé keys öþţîöñ:

{ "rules": { "neokapi-i18n/prefer-t-for-label-expr": ["warn", { "keys": ["label", "cta"] }] } }

Šüþþŕéšš ƒàļšé þöšîţîṽéš öñ à šþéçîƒîç éļéḿéñţ ŵîţĥ translate="no":

// file.name is an OS path, not UI copy
<option value={f.path} translate="no">
{f.name}
</option>

prefer-t-for-label-props

Ţĥé çļàššîç "ļàƃéļ ĥîđđéñ îñ à đàţà àŕŕàý" þàţţéŕñ, ţĥé đéçļàŕàţîöñ šîđé öƒ ţĥé šàḿé îđéà:

// ✗ 'System' never gets extracted
const THEMES = [
{ value: "system", label: "System" },
{ value: "light", label: "Light" },
];
return THEMES.map(({ value, label }) => <button>{label}</button>);

// ✓ the literals are now visible to extraction
const THEMES = [
{ value: "system", label: t("System") },
{ value: "light", label: t("Light") },
];

Öñļý îñ ţĥé recommendedStrict þŕéšéţ ƃý đéƒàüļţ ƃéçàüšé îţ çàñ ƒîŕé öñ îñţéŕñàļ-öñļý đàţà àŕŕàýš. Šàḿé ñàŕŕöŵ ķéý ļîšţ àš prefer-t-for-label-expr. Ţüŕñ öñ îñđîṽîđüàļļý:

{ "rules": { "neokapi-i18n/prefer-t-for-label-props": "error" } }

Ḿöđüļé-ļéṽéļ t() ĝöţçĥà

Àļļ t()-ŵŕàþþîñĝ ƒîẋéš àƃöṽé àššüḿé ţĥé çàļļš ĥàþþéñ þéŕ ŕéñđéŕ. À ḿöđüļé-ļéṽéļ çöñšţ ƒŕééžéš éàçĥ t() çàļļ àţ ŵĥàţéṽéŕ ţĥé đîçţ šàîđ ŵĥéñ ţĥé ḿöđüļé ƒîŕšţ ļöàđéđ, ţýþîçàļļý ţĥé ƒàļļƃàçķ ļàñĝüàĝé, ƃéçàüšé ţŕàñšļàţîöñš ļöàđ àƒţéŕ ţĥé îñîţîàļ îḿþöŕţ.

// ✗ Frozen at load time. "Utility" will still say "Utility" in pseudo.
const categoryMeta = {
utility: { label: t("Utility") },
};

// ✓ Per-render: each invocation picks up the current dict.
function categoryMeta(cat: string) {
switch (cat) {
case "utility":
return { label: t("Utility") };
// …
}
}

Ŵŕàþ ñöñ-ţŕîṽîàļ ļööķüþ ţàƃļéš îñ à ƒüñçţîöñ ţĥàţ ŕéţüŕñš ƒŕéšĥ ṽàļüéš þéŕ ŕéñđéŕ. Šéé Ţĥé t() éšçàþé ĥàţçĥ → Ḿöđüļé-ļéṽéļ ĝöţçĥà ƒöŕ ḿöŕé.

ÇÎ éñƒöŕçéḿéñţ

Ţŵö ŵàýš ţö ƒàîļ ţĥé ƃüîļđ öñ ŵàŕñîñĝš:

Ļîñţ šţéþ:

vp lint # or: oxlint / eslint

Ñöñ-žéŕö éẋîţ ŵĥéñ àñý ŕüļé àţ šéṽéŕîţý error ƒîŕéš. Ŵîŕé îţ àļöñĝšîđé ýöüŕ ţýþéçĥéçķ šţéþ îñ ÇÎ (öŕ ŕüñ îţ ƒŕöḿ à Ĝîţ þŕé-çöḿḿîţ ĥööķ).

Éẋţŕàçţ ÇĻÎ:

vp neokapi-i18n extract --strict

Éẋîţš ñöñ-žéŕö îƒ ţĥé éẋţŕàçţöŕ ŕéçöŕđéđ àñý ŵàŕñîñĝ (unknown-component, ternary-attr-complex, dyn-label-splice). Ĝööđ ƒöŕ çàţçĥîñĝ àüţĥöŕîñĝ îššüéš ţĥé ļîñţ ŕüļéš çàñ'ţ šéé ƒŕöḿ à šîñĝļé ƒîļé.

Þļüĝîñ (ƃüîļđ-ţîḿé):

vite.config.ts
import neokapi from "@neokapi/i18n-react/vite";

export default {
plugins: [neokapi({ warningsAsErrors: process.env.CI === "true" })],
};

Þŕöḿöţéš ţŕàñšƒöŕḿ-šîđé ŵàŕñîñĝš (üñķñöŵñ-çöḿþöñéñţ, éţç.) ţö ţĥŕöŵñ ƃüîļđ éŕŕöŕš. Üšé process.env.CI ţö ķééþ ļöçàļ đéṽ éŕĝöñöḿîç.

Éẋçļüđîñĝ ƒîẋţüŕé çöđé

Šţöŕîéš, ḿöçķš, àñđ ƒîẋţüŕéš đöñ'ţ üšüàļļý ŵàŕŕàñţ ţĥé šàḿé î18ñ ŕîĝöŕ àš šĥîþþéđ çöḿþöñéñţš. Ţŵö çöḿþļéḿéñţàŕý ŵàýš ţö éẋçļüđé ţĥéḿ:

Ƒŕöḿ ļîñţ: ţĥé .oxlintrc.json overrides ƃļöçķ (šéé àƃöṽé).

Ƒŕöḿ éẋţŕàçţîöñ: ţĥé --ignore ƒļàĝ:

package.json
{
"scripts": {
"extract": "vp neokapi-i18n extract --out i18n/ --ignore 'src/stories/**' --ignore '**/*.test.tsx'"
}
}

Ţĥé ƒļàĝ îš ŕéþéàţàƃļé àñđ þàššéđ ţĥŕöüĝĥ ţö Ñöđé'š fs/promises.glob exclude öþţîöñ.