@servicenow/eslint-plugin-aiux-i18n
v0.2.0
Published
ESLint rules for i18n extraction coverage in AIUX apps. Pairs with `aiux dev --pseudo` (runtime visual) to catch translation gaps at build time.
Downloads
226
Readme
@servicenow/eslint-plugin-aiux-i18n
ESLint rules for catching i18n extraction gaps at build time in AIUX apps. Pairs with aiux dev --pseudo (the runtime visual signal): same gaps, two different surfaces.
Why
The AST extractor in core/sdk/src/utils/swc-decorators.js only captures string-literal arguments to i18n.getMessage(). Calls with variable arguments (getMessage(item.label)) and strings hardcoded outside getMessage() will ship as English in every locale — they never reach sys_ui_message, so non-English builds fall back to the source key.
These rules catch those patterns at lint time.
Install
pnpm add -D @servicenow/eslint-plugin-aiux-i18nUsage
Flat config:
import aiuxI18n from '@servicenow/eslint-plugin-aiux-i18n';
export default [
...aiuxI18n.flatConfigs.recommended,
// or individual rules:
{
plugins: {'@servicenow/aiux-i18n': aiuxI18n},
rules: {
'@servicenow/aiux-i18n/getmessage-no-variable-arg': 'error',
'@servicenow/aiux-i18n/no-hardcoded-html-text': 'warn',
'@servicenow/aiux-i18n/no-hardcoded-translatable-attrs': 'warn'
}
}
];Rules
getmessage-no-variable-arg
Flags i18n.getMessage(<non-literal>) and i18n.mark(<non-literal>) calls. The AST extractor only sees the literal string at the call site; variable args mean the literal value is never captured and never ships to translation.
// ✗ extractor can't see 'Manage Pods'
const label = i18n.getMessage(item.label);
// ✓ extractor captures the literal
const label = i18n.getMessage('Manage Pods');
// ✓ mark in module-scope config + getMessage in render
const NAV = [{label: i18n.mark('Manage Pods'), path: '/manage-pods'}];
// later in render:
const label = i18n.getMessage(item.label); // still flagged — see "Convention" belowConvention to make config-driven UIs translatable
If your config arrays put labels in object literals and your render code does getMessage(item.label), the extractor never sees the literal. Three ways out:
i18n.markin the config +i18n.getMessagein render (recommended): the AST extractor recognises both, andmarkis a no-op at runtime — render-timegetMessage(item.label)does the actual lookup with the current locale.- Getter functions:
{label: () => i18n.getMessage('Manage Pods')}, thenitem.label()in render. - Inline templates: skip the data-driven config and write
${i18n.getMessage('Manage Pods')}directly.
The lint rule flags the variable-arg case for both getMessage and mark. To allow getMessage(item.label), use the mark pattern: the extractor catches the literal at module load via mark, and the variable-arg getMessage in render does the actual lookup. Both happen — the lint rule flags getMessage(item.label) to prompt that conversation; opt out per-line with // eslint-disable-next-line when the mark pattern is wired up.
no-hardcoded-html-text
Flags user-visible text directly inside Lit html`...` templates.
// ✗
return html`<button>Save changes</button>`;
// ✓
return html`<button>${i18n.getMessage('Save changes')}</button>`;
// ✓ explicit opt-out (intentional static text)
return html`<span data-i18n-skip>ServiceNow</span>`;Skipped by default: text inside <script>, <style>, <code>, <pre>, <noscript>, <svg> and its children; pure numeric / punctuation text; single characters.
no-hardcoded-translatable-attrs
Flags string-literal values for aria-label, aria-description, placeholder, title, alt, label, and other screen-reader-visible attributes.
// ✗
return html`<button aria-label="Close dialog">×</button>`;
// ✓
return html`<button aria-label=${i18n.getMessage('Close dialog')}>×</button>`;
// ✓ opt-out
return html`<input placeholder="ServiceNow" data-i18n-skip />`;Configurable extra attributes:
{
'@servicenow/aiux-i18n/no-hardcoded-translatable-attrs': [
'warn',
{additionalAttributes: ['data-label', 'data-tooltip']}
];
}Pairing with aiux dev --pseudo
This plugin catches issues at lint time; aiux dev --pseudo shows them at runtime — strings the extractor caught render in accented Latin (「Šàṽé____」), strings it missed render in plain ASCII. The two surfaces complement each other: use the linter to enforce the contract, use pseudo-loc to spot-check coverage in the browser.
