eslint-config-canopy
v5.5.1
Published
A standard eslint configuration for Canopy frontend developers
Maintainers
Keywords
Readme
canopy-eslint
A standard eslint config for Canopy frontend developers. This assumes that you're using babel and babel-eslint babel-eslint.
Installation
yarn add -D eslint-config-canopyUsage
Your project needs to use the new ESLint v9 flat config system. Create an eslint.config.mjs file in your project root (Remove .eslintrc if it exists):
import canopyConfig from "eslint-config-canopy";
export default [
{ // Specify directories/files to ignore here
ignores: ["src/create-edit-client-old/**/*"],
},
...canopyConfig,
{ // Override rules here
rules: {
"@typescript-eslint/no-unused-vars": "off",
},
},
];Plugin rules
This package ships a canopy ESLint plugin with Canopy-specific rules. Most of them
are already enabled at warn by the shared config exported from this package, so
extending eslint-config-canopy turns them on. See the table below for what each one
reports, and docs/rules/ for the details.
The plugin is also exposed on its own via the eslint-config-canopy/plugin sub-export,
so you can register it directly to change a severity, disable a rule, or opt into one
that ships off by default:
import canopyConfig from "eslint-config-canopy";
import canopyPlugin from "eslint-config-canopy/plugin";
export default [
...canopyConfig,
{
plugins: { canopy: canopyPlugin },
rules: {
"canopy/no-cp-class-in-tw": "error",
},
},
];Network access
The shared config also restricts raw network access, using ESLint's built-in rules rather than a Canopy one:
| Restriction | Rule |
| --- | --- |
| The fetch global | no-restricted-globals — use fetcher!sofe, which adds auth headers, the CSRF token, tenant context, error routing and Sentry breadcrumbs |
| axios imports | no-restricted-imports — same reason |
no-restricted-globals resolves through scope, so a locally declared or imported
fetch (a node-fetch shim, a polyfill, a parameter) is not reported — only the
ambient browser global. Server-side packages where fetch() is correct can turn this
off with 'no-restricted-globals': 'off'.
Available rules
| Rule | Description |
| --- | --- |
| canopy/no-cp-class-in-tw | Disallows Canopy cp-* class tokens inside tw(...) calls. Tailwind's tw() helper applies a per-app prefix to every token, so tw("cp-body") produces a broken class like fo-cp-body. Use always("cp-body", tw(...)) or place the cp-* class outside tw(). Offers a suggestion fix for flat string-literal calls. |
| canopy/no-class-ternary | Disallows a class-selecting ternary (both branches non-empty) in a JSX className attribute or a tw(...) / always(...) call. Use toggle(cond, whenTrue, whenFalse) instead. Auto-fixable. |
| canopy/no-conditional-class | Disallows an empty-branch ternary (cond ? "x" : "") or a cond && "x" short-circuit in a JSX className attribute or a tw(...) / always(...) call. Use maybe(cond, "x") instead. Auto-fixable. |
| canopy/no-window-auth-globals | Disallows reading window.loggedInUser, window.tenant, or window.betas. These are snapshots that never update and are undefined before auth resolves. Use useWithUserAndTenant() / UserTenantProps / useBetas() from cp-client-auth!sofe. Writes are allowed, so bootstraps and test mocks are unaffected. |
| canopy/no-tolocalestring-for-dates | Disallows .toLocaleDateString(), .toLocaleTimeString(), and construction of an Intl.DateTimeFormat. Use Luxon DateTime with a Canopy preset such as DateTime.DATE_SHORT. .toLocaleString() is not reported — it is Luxon's own correct API and is also how numbers get thousands separators. |
| canopy/no-hardcoded-font-size | Disallows font sizes no theme can reach: Tailwind arbitrary lengths like text-[13px] and literal fontSize values in style props. The named scale (text-sm…text-9xl) is allowed — it resolves through the Tailwind theme. Colours, alignment, and decoration sharing the text- prefix are not reported, nor are computed values or inherit. |
| canopy/no-license-check-for-feature-gating | Disallows hasLicense() imported from cp-client-auth or cp-client-auth!sofe (including aliases) when its result is used as a condition (if, ternary, &&/||, or behind !). ?? is not reported, nor is a same-named local that shadows the import. A license is what the firm bought; a permission is what the user may do — use useHasAccess() to gate features. Reading license state for billing, seat counts, or analytics is not reported. |
| canopy/require-staletime-in-usequery | Requires an explicit staleTime when useQuery / useInfiniteQuery options are an object literal. Without it a query refetches on every mount, which in the single-spa shell means every navigation. Query-factory calls, spreads, and the legacy positional signature are not reported. |
| canopy/require-subscribe-cleanup | Requires an effect that calls .subscribe() in its own scope to return a cleanup function, so the subscription does not outlive the component. Any returned value satisfies the rule; nested functions are not searched, which also catches a cleanup returned from the wrong function. |
| canopy/require-subscribe-error-handler | Requires an error handler on .subscribe() when the value handler is an inline function — a second positional argument, an error key, or a spread observer satisfies it. Without one, stream errors are swallowed and never reach Sentry. Handlers passed by reference are not reported, since they are indistinguishable from a Pusher channel name; exactly one non-RxJS receiver (a Zustand store) is reported ecosystem-wide. |
| canopy/comment-length | Limits a comment to a configurable length (default 240 characters, ~2-3 lines). A run of consecutive // lines is measured together, so switching prose to stacked // lines does not evade the limit; each /* */ block is measured whole. Exempt are structured JSDoc blocks (a /** */ carrying at least one @tag) and any comment containing a URL; a tagless doc block and a directive comment are not, so a genuinely long comment otherwise needs an explicit eslint-disable. |
Design-system rules
eslint-config-canopy/design-system is an opt-in export that checks how Understory
components are styled, using @shadcn/lint. It is
not part of the default config. Add it after canopyConfig:
import canopyConfig from "eslint-config-canopy";
import canopyDesignSystem from "eslint-config-canopy/design-system";
export default [
...canopyConfig,
...canopyDesignSystem,
];Requirements, from @shadcn/lint: ESLint 9.30 or later, Node.js 20.19 or later, and Tailwind v4.
A component counts as Understory when it is imported from @canopytax/understory. Classes
are read from className and from the arguments to tw, always, maybe and toggle.
| Rule | Level | Reports |
| --- | --- | --- |
| shadcn/no-restyle | error | A className on an Understory component that changes more than its layout. Margin, width and position are allowed; padding, color and typography are not. |
| shadcn/no-raw-colors | error | Tailwind palette colors such as bg-blue-500. |
| shadcn/no-arbitrary-values | error | Arbitrary values such as w-[137px]. |
All three report at error, so a repo that turns on the export must fix its findings first
or turn off individual rules in its own eslint.config.
These are allowed. The color and arbitrary-value exceptions apply to no-raw-colors and
no-arbitrary-values only; on an Understory component, no-restyle still reports a color class.
| Allowed | Reason |
| --- | --- |
| cp-* and cps-* classes | Understory and legacy canopy-styleguide classes. Tailwind does not generate them. |
| bg-[var(--cp-color-*)] and the same form for text, border, fill, stroke, outline, ring, divide | Understory's --cp-color-* variables are a supported way to apply color. |
| The gray scale, such as bg-gray-100 | Understory's theme.css maps gray onto its own variables, but the name collides with Tailwind's default gray, so no-raw-colors would otherwise report it. Understory's brand, error, warning and success scales are not Tailwind palette names and are never reported. |
| text-[…] | Sizes are checked by canopy/no-hardcoded-font-size. |
| grid-cols-[…] and grid-rows-[…] | Grid templates have no scale to use instead. |
| Padding and gap on CpWell, CpCard, CpCardBody, CpCardHeader, CpCardFooter, CpArea | These containers leave their spacing to the caller. |
| Gap, but not padding, on CpModalBody, CpModalFooter, CpOverlayBody | These sections set their own padding. Gap only spaces their children. |
Not included yet:
shadcn/no-unknown-classesis planned. Repos whose Tailwind entry is generated by canopy-webpack-config need a lint entry file first.shadcn/no-inline-stylesandshadcn/require-static-classesreport too much on current code.
