Skip to main content

ESLint Plugin

@allxsmith/eslint-plugin-bestax reports bestax-bulma code that does not do what it looks like it does.

The helper props validate by membership: useBulmaClasses checks a value against the list it accepts and emits nothing when it does not match. There is no throw, no console warning, and no fallback. So this renders, and says nothing about itself:

<Box textAlign="center" mt="1rem" textColor="blue" />

Every helper prop on that element is wrong, and not one of them emits a class: Bulma spells it centered, the spacing scale is 06 and auto rather than CSS lengths, and blue is not one of its colours.

Where this helps, and where TypeScript already does

The helper props are typed as literal unions, so in a .tsx file tsc already rejects every one of those, and it does it well: textAlign="center" gets TS2820 with its own "Did you mean 'centered'?".

So valid-helper-value is for the places that check does not reach: JavaScript and JSX projects, and lint stages that run before or instead of typechecking. The preset matches no .md/.mdx, so code in a documentation fence is out of reach without an ESLint markdown processor, and even then each fence needs its own import for the elements to resolve.

no-deprecated-props and no-inert-flex-props are additive everywhere. No type error marks a deprecated prop, and none marks a flex prop that emits nothing.

Setup

Requires ESLint 10 with flat config. The plugin is ESM-only, which does not rule out a CommonJS config: an eslint.config.cjs can require() it on a Node that supports require(esm), reading configs.recommended straight off the result. Without that support it throws ERR_REQUIRE_ESM, and an import from eslint.config.mjs works either way.

pnpm add -D @allxsmith/eslint-plugin-bestax

The recommended config registers the plugin as @allxsmith/bestax, matches .js, .mjs, .cjs, .jsx and .tsx, enables JSX parsing, and turns on every rule that reports broken code as an error. no-color-as-surface is left off; see below.

It deliberately sets no parser, so that whatever you configure for TypeScript survives. Which means a TypeScript project has to supply one:

eslint.config.js
import bestax from '@allxsmith/eslint-plugin-bestax';
import parser from '@typescript-eslint/parser';

export default [
{ files: ['**/*.{ts,tsx}'], languageOptions: { parser } },
bestax.configs.recommended,
];

Any TypeScript setup that sets a parser works the same way, typescript-eslint included, precisely because the preset does not set one of its own.

If the project contains no .tsx at all, the preset alone is enough:

eslint.config.js
import bestax from '@allxsmith/eslint-plugin-bestax';

export default [bestax.configs.recommended];
The parser is a requirement, not a nicety

The preset matches .tsx, because that is where a TypeScript project's JSX lives and the rules have to reach it. With no parser supplied it hands that file to the default one, which stops at the first type annotation: Parsing error: The keyword 'interface' is reserved.

The failure is loud on purpose. Leaving .tsx out of the preset's own files was the alternative, and it is worse: a TypeScript project would then get no rules on any .tsx file, silently, even with a parser configured, because a flat config object only applies to what its own files matches.

To choose rules yourself, note that files is doing real work here: a flat config object without it inherits ESLint's default **/*.{js,mjs,cjs} set, so leaving it out means your .jsx and .tsx files are never linted and you get no error saying so.

eslint.config.js
import bestax from '@allxsmith/eslint-plugin-bestax';

export default [
{
files: ['**/*.{jsx,tsx}'],
plugins: { '@allxsmith/bestax': bestax },
languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } } },
rules: {
'@allxsmith/bestax/valid-helper-value': 'error',
'@allxsmith/bestax/no-deprecated-props': 'warn',
},
},
];

What it will not do

Every rule resolves elements through the import, and through scope, so neither your own <Box> nor a local that shadows the imported one is linted against Bulma's rules. Every rule skips a value it cannot read as a literal: a variable, or a template with an interpolation.

A spread is treated by what it can change. no-color-as-surface and no-inert-flex-props go silent, because a spread may carry the very prop that would make the code correct. no-deprecated-props still reports, since the deprecated prop is written right there, but offers no fix. valid-helper-value judges only the values that actually render, so a spread before the attribute leaves it reporting while a spread after it does not — JSX is last-wins throughout, spreads included.

No autofix here changes what the code renders. A false report on correct code is worse than a missed one, because it teaches people to switch the rule off.

The recommended set has no style rules. Nothing in it has an opinion about whether you should use a helper prop, only about whether the one you wrote does anything. The one rule that is about spelling rather than correctness, no-color-as-surface, ships switched off for exactly that reason.

Rules

valid-helper-value

Reports helper values the library will drop.

<Box textAlign="center" /> // ✗ Did you mean `centered`?
<Box textSize="8" /> // ✗ the scale is 1–7
<Box mt="1rem" /> // ✗ the scale is 0–6 and auto
<Box textColor="blue" /> // ✗ not a Bulma colour
<Box float="center" /> // ✗ float pulls left or right
<Box shadow /> // ✗ reads as a boolean; the value is `shadowless`

The valid values are read from the library's own exported tuples at lint time, through its /constants subpath, rather than from a list baked into the plugin. Those tuples come from the copy of the library this plugin resolves, which in an ordinary deduped install is the one your app uses; across a major bump it may not be, so keep the two in step.

One family is outside its reach, knowingly: component-specific color props have their own unions, so <Button color="ghost"> is correct and the value rule has no business reporting it.

There is also one element where a helper name means something else, and the rule knows it. Theme accepts a prop for every Bulma CSS variable, and --bulma-radius gives it a radius prop, so on Theme that prop sets the variable instead of emitting a class. The rule skips radius on Theme alone: every other helper prop on it, and radius on everything else, is still checked.

It knows the documented extras, so these are all accepted:

<Box display="none" /> // ✓ display also takes `none`
<Box textColor="inherit" /> // ✓ and the CSS-wide keywords
<Box bgColor="scheme-main-bis" /> // ✓ bgColor also takes the scheme colours

no-deprecated-props

Reports props the library has deprecated, and fixes the renames.

<Button isFullWidth /> // ✗ → isFullwidth (fixable)
<Columns gapSize="3" /> // ✗ → gap (fixable)
<Icon icon="rocket" /> // ✗ → name (reported, not fixed)
<Sidebar fullWidth /> // ✗ → isFullwidth (fixable)
<Tabs color="info" /> // ✗ no `.tabs.is-<color>` CSS exists — no fix
<Tags isMultiline /> // ✗ never had an effect — no fix

A rename is autofixable when the swap cannot change what renders. Everything else is reported with the library's own reason and no fix:

  • A prop retired outright, which emits a class no shipped CSS matches or never did anything, so there is nothing to rename it to.
  • A note naming more than one replacement, such as Icon's libraryFeatures, which became variant and features.
  • iconname, because the two do not read their value the same way: the library keeps only the last space-separated segment of icon and never splits name, so renaming icon="material-symbols-outlined home" would turn the home ligature into that string as literal text.
  • Two deprecated props on one element that rename to the same target, since the library picks between them in a fixed order and promoting either one changes the result.

The table is generated from the library's TSDoc, so a rename reaches the plugin with the release that makes it.

No fix is offered when the element already sets the replacement, since rewriting would collapse two props into a duplicate and the library documents which one wins:

<Button isFullWidth isFullwidth /> // ✗ reported, not fixed

no-color-as-surface

Opt-in

This rule is not in recommended. Unlike the others it reports code that works: color is a documented alias, and an author who writes it may well want coloured text. Turn it on if you want the call site to say which it meant.

On the content elements, color is a text-colour alias: it renders has-text-<color>, exactly like textColor, and there is no .box.is-<color> CSS for it to mean anything else.

<Box color="primary" /> // ✗ this colours TEXT → fixes to textColor
<Box textColor="primary" /> // ✓ text
<Box bgColor="primary" /> // ✓ a coloured surface

It stays silent when the element sets a background explicitly, because then color is unambiguously the text half of a deliberate pairing:

<Box bgColor="info" color="primary" /> // ✓ surface and text, both meant

Elements with a real is-<color> modifier are untouched, and the distinction follows the compound tree:

<Button color="primary" /> // ✓ a filled button
<Hero color="info" /> // ✓ a filled hero
<Buttons.Button color="primary" />// ✓ a real variant
<Buttons color="primary" /> // ✗ the wrapper takes the text alias

no-inert-flex-props

The flex container props emit nothing unless a display prop is flex or inline-flex — so this looks like it centres its children and does nothing:

<Box justifyContent="center" /> // ✗
<Box display="flex" justifyContent="center" /> // ✓
<Box displayTablet="flex" alignItems="center" />// ✓ a responsive band counts

The flex child props describe the element inside somebody else's container, so they always apply and are never reported:

<Box flexGrow="1" flexShrink="0" alignSelf="center" /> // ✓