@starci/eslint-canon-fe 4.0.0 → 5.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/README.md +34 -16
- package/brand-values.mjs +174 -0
- package/class-names.mjs +1 -1
- package/client-boundary.mjs +199 -0
- package/comments.mjs +144 -190
- package/docs/brand-values.md +30 -0
- package/docs/client-boundary.md +99 -0
- package/docs/e2e-shape.md +161 -0
- package/docs/env-owner.md +52 -0
- package/docs/formatting.md +30 -0
- package/docs/hooks-folder.md +29 -0
- package/docs/hygiene.md +111 -0
- package/docs/lint-escape-hatch.md +32 -0
- package/docs/lists.md +74 -0
- package/docs/native-controls.md +73 -0
- package/docs/next-conventions.md +206 -0
- package/docs/size-and-state-budget.md +73 -0
- package/docs/spec-quality.md +139 -0
- package/docs/translation.md +50 -0
- package/docs/transport.md +168 -0
- package/docs/type-safety.md +95 -0
- package/e2e-shape.mjs +348 -0
- package/env-owner.mjs +134 -0
- package/file-layout.mjs +1 -2
- package/formatting.mjs +106 -0
- package/hooks-folder.mjs +119 -0
- package/hygiene.mjs +235 -0
- package/icon.mjs +210 -211
- package/index.mjs +290 -131
- package/lib/ast.mjs +94 -0
- package/lib/path.mjs +2 -0
- package/lib/scope.mjs +42 -0
- package/lib/why.mjs +304 -0
- package/lint-adoption.mjs +15 -52
- package/lint-escape-hatch.mjs +91 -56
- package/lists.mjs +197 -0
- package/loading.mjs +163 -164
- package/naming.mjs +143 -143
- package/native-controls.mjs +112 -0
- package/next-conventions.mjs +346 -0
- package/package.json +8 -4
- package/served-locale.mjs +160 -160
- package/shape-slot.mjs +1 -2
- package/size-and-state-budget.mjs +196 -0
- package/spec-quality.mjs +239 -0
- package/the-split.mjs +133 -134
- package/tokens.mjs +267 -268
- package/translation.mjs +262 -122
- package/transport.mjs +404 -0
- package/type-safety.mjs +156 -74
- package/typography.mjs +82 -83
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 5.1.2 - 2026-09-30
|
|
4
|
+
|
|
5
|
+
- **Fix: `timer-needs-effect-cleanup` refused a correctly cleaned-up timer outside an effect.** nivo-fe `useNow` starts its `setInterval` inside the `subscribe` given to `useSyncExternalStore` and clears it in the unsubscribe that `subscribe` returns; the rule knew only `useEffect` cleanups and reported it. A timer is now also accepted when a function enclosing it directly returns a function (arrow expression body, or the last top-level `return`) that calls `clearTimeout`/`clearInterval` (bare, `window.` or `globalThis.`) on the same identifier the timer handle was stored in (`const id = ...` or `id = ...`). Detection is structural, not by name. A timer with no stored handle, a returned cleanup that clears another handle, or no returned cleanup at all is still refused.
|
|
6
|
+
|
|
7
|
+
## 5.1.1 - 2026-09-30
|
|
8
|
+
|
|
9
|
+
- **Fix: `use-client-only-at-boundary` knew only the app layout.** A workspace package keeps its grammar tiers directly under `src/` (`packages/<pkg>/src/{composites,branches,leaves}`, the `fe.package.ui` slot), but the rule accepted the directive only under `/components/...`, so nivo-fe's `packages/nivo-ui` reported 13 false findings. The tier names come from `PACKAGE_TIERS` in `lib/scope.mjs` (the slot's layers, no repo name); a package branch or leaf is a boundary, a package composite is not, and the app layout is unchanged.
|
|
10
|
+
- **Fix: the `monorepo` layout scanned only `packages/ui`.** `LAYOUT_GLOBS.monorepo` named the shared package literally (`packages/ui/src/**`), so a workspace package with any other name (nivo-fe's `packages/nivo-ui`) was governed by no starci-fe rule and a deliberate bare `<img>` there printed nothing. The source glob is now `packages/*/src/**/*.{ts,tsx}` and the e2e glob adds `packages/*/e2e/**/*.{ts,tsx}`; every package is judged by the same rules.
|
|
11
|
+
|
|
12
|
+
## 5.1.0 - 2026-09-29
|
|
13
|
+
|
|
14
|
+
Round 2 of the FE enforcers, measured on nivo-fe, starci-next-fe and miamia-fe. Twenty-two rules, every one an error; no rule of 5.0.0 changed. Each is registered in `knowledge/hfs/rules.yaml` under the catalogue rule it enforces, with a Vietnamese failure code.
|
|
15
|
+
|
|
16
|
+
- **Type safety (R22, `FE_TYPE_ESCAPE`)**: `no-type-assertion` (`as T`, `<T>x`; `as const` and `as unknown` stay), `no-non-null-assertion`, `no-explicit-any`. `no-double-cast` now has a why entry.
|
|
17
|
+
- **Lists (R65, `FE_LIST_KEY`)**, new law `lists`: `list-item-has-key`, `no-index-key`, `no-inline-literal-prop-in-list`.
|
|
18
|
+
- **Runtime hygiene (R65, R50)**, new law `hygiene`: `timer-needs-effect-cleanup` (`FE_EFFECT_CLEANUP`), `no-data-fetch-in-effect` (`FE_EFFECT_FETCH`), `no-empty-catch` (`FE_SWALLOWED_ERROR`), `no-console` (`FE_CONSOLE_CALL`).
|
|
19
|
+
- **Formatting (R59, `FE_I18N_FORMATTER`)**, new law `formatting`: `use-intl-formatter` (`toLocale*String`, `new Intl.*Format`, displayed `toFixed`, currency glued to a template, date libraries).
|
|
20
|
+
- **Next conventions**: `page-exports-metadata` (R54, `FE_PAGE_METADATA_MISSING`), `no-null-suspense-fallback` (R53, `FE_SUSPENSE_NULL_FALLBACK`), `navigation-from-intl` and `no-native-anchor` (R59, `FE_I18N_NAVIGATION`), `no-hardcoded-route` (R57, `FE_ROUTE_HARDCODED`).
|
|
21
|
+
- **Client boundary (R55)**: `client-no-server-import` (`FE_CLIENT_SERVER_IMPORT`), `web-storage-only-in-modules` (`FE_STORAGE_OUTSIDE_MODULES`), `no-dangerous-html` (`FE_DANGEROUS_HTML`).
|
|
22
|
+
- **Native controls (R62, `FE_NATIVE_IMAGE`)**: `no-native-img`, `image-has-size`.
|
|
23
|
+
- **Transport (R51, `FE_OUTCOME_KIND_UNHANDLED`)**: `outcome-kinds-exhaustive`.
|
|
24
|
+
- `lib/ast.mjs` holds the JSX attribute, call-name and effect-callback readers the new laws share. One doc page per new law; type-safety gained its page.
|
|
25
|
+
|
|
26
|
+
## 5.0.0 - 2026-09-29
|
|
27
|
+
|
|
28
|
+
HFS Phase 0 item 0.6. Breaking: the factory signature, the retired rules and the level of every rule.
|
|
29
|
+
|
|
30
|
+
- **`starciFeConfig({ layout })`** returns two flat-config blocks (source and e2e) and owns the law: every published rule at `error` (the factory throws if one is not), the React Hooks rules (`eslint-plugin-react-hooks` 7, recommended set lifted to `error`, `set-state-in-effect` and `refs` required), and `linterOptions` (`noInlineConfig` plus `reportUnusedDisableDirectives: "error"`). The `plugin`, `recommended` and `linterOptions` inputs are gone. `eslint-plugin-react-hooks >=7` is a new peer dependency.
|
|
31
|
+
- **`vn-ok` is removed entirely.** `no-second-language-in-source` and `no-hardcoded-copy-in-vocabulary` are replaced by `no-hardcoded-copy` (R58): no literal copy at any tier, in any language, no pragma, no endonym or `resources/` exemption. Same notion of literal as nivo-fe's `check-i18n-catalog.mjs` (a word is two or more letters). A `vn-ok:` comment is now itself a finding of `no-inline-lint-config`, which also refuses `@ts-ignore`, `@ts-expect-error`, `@ts-nocheck`, `eslint-env` and inline `eslint rule:` config, and now covers specs and the e2e tree.
|
|
32
|
+
- New laws and rules: `env-owner` (R49: `no-env-outside-config`, `no-hardcoded-endpoint-fallback`), `transport` (R50-R52: `fetch-only-in-api-client`, `client-fetch-has-signal`, `no-shared-transport-state`, `client-maps-auth-to-refused`, `no-http-status-collapse`, `no-hand-typed-wire`), `client-boundary` (R55: `use-client-only-at-boundary`), `hooks-folder` (R56 lint half: `hooks-folder-holds-hooks-only`), `next-conventions` (`no-middleware-file`, `locale-segment-is-locale`, `no-second-i18n-stack`, `html-lang-from-locale`), `brand-values` (R61 TypeScript half: `no-raw-brand-value`), `native-controls` (R62: `no-native-form-control`), `size-and-state-budget` (R65: `component-line-budget`, `unit-hook-budget`, `no-hand-rolled-polling`), `e2e-shape` (R66, seven rules on `e2e/**` and `playwright.config.*`), `spec-quality` (R67 and the real-catalogue half of R60: six rules).
|
|
33
|
+
- `sourceRecommended` and `e2eRecommended` are exported beside `recommended` (their union); the effective-config audit compares a production probe against `sourceRecommended`.
|
|
34
|
+
- `why[rule]` is `{ code, vi, fixVi }`: the catalogue finding code and the Vietnamese why for every rule of the catalogued laws.
|
|
35
|
+
- One doc page per law under `docs/`.
|
package/README.md
CHANGED
|
@@ -1,32 +1,47 @@
|
|
|
1
1
|
# @starci/eslint-canon-fe
|
|
2
2
|
|
|
3
|
-
The StarCi front-end lint canon for
|
|
3
|
+
The StarCi front-end lint canon for Next.js and TypeScript applications. It keeps
|
|
4
4
|
component ownership, accessibility, loading behavior, vendor boundaries, translations, naming,
|
|
5
5
|
and colocated class-name composition consistent without prescribing a custom rendering protocol.
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npm i -D @starci/eslint-canon-fe
|
|
8
|
+
npm i -D @starci/eslint-canon-fe eslint-plugin-react-hooks
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
```js
|
|
12
|
-
import
|
|
13
|
-
|
|
14
|
-
export default [starciFeConfig({
|
|
15
|
-
layout: "single-app",
|
|
16
|
-
plugin: starciFe,
|
|
17
|
-
recommended,
|
|
18
|
-
linterOptions,
|
|
19
|
-
})]
|
|
12
|
+
import { starciFeConfig } from "@starci/eslint-canon-fe"
|
|
13
|
+
|
|
14
|
+
export default [...starciFeConfig({ layout: "single-app" })]
|
|
20
15
|
```
|
|
21
16
|
|
|
22
|
-
The
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
The factory returns two flat-config blocks and owns everything about the law: which rules are on
|
|
18
|
+
(every one, at `error` - nothing is off and nothing is a warning), the React Hooks rules
|
|
19
|
+
(`eslint-plugin-react-hooks` 7, its recommended set lifted to `error`, including
|
|
20
|
+
`set-state-in-effect` and `refs`), and the inline-directive fence (`noInlineConfig` plus
|
|
21
|
+
`reportUnusedDisableDirectives`). A repository names its layout and nothing else.
|
|
22
|
+
|
|
23
|
+
- The **source** block governs `src/**` (`single-app`) or `packages/*/src/**` (every workspace package, not one named `ui`) and `apps/*/src/**`
|
|
24
|
+
(`monorepo`). A package keeps its grammar tiers at `src/{composites,branches,leaves}`; the client-boundary rule reads that layout as well as an app's `src/components/`.
|
|
25
|
+
- The **e2e** block governs `e2e/**` and `playwright.config.*` with the e2e rules and the
|
|
26
|
+
escape-hatch fence.
|
|
27
|
+
|
|
28
|
+
`recommended`, `sourceRecommended`, `e2eRecommended`, `why`, `audits` and the plugin itself are also
|
|
29
|
+
exported. `sourceRecommended` is the map to compare against a production probe file with
|
|
30
|
+
`audits["effective-config"]`; `why[rule]` is `{ code, vi, fixVi }`, the finding code and the
|
|
31
|
+
Vietnamese why the harness quotes.
|
|
25
32
|
|
|
26
33
|
## Rules
|
|
27
34
|
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
Each law has a page under [`docs/`](docs) with, for every rule, an invalid and a valid example and
|
|
36
|
+
its Vietnamese why. New in 5.0: [env-owner](docs/env-owner.md), [transport](docs/transport.md),
|
|
37
|
+
[client-boundary](docs/client-boundary.md), [hooks-folder](docs/hooks-folder.md),
|
|
38
|
+
[next-conventions](docs/next-conventions.md), [translation](docs/translation.md) (no literal copy at any
|
|
39
|
+
tier, no `vn-ok`), [brand-values](docs/brand-values.md), [native-controls](docs/native-controls.md),
|
|
40
|
+
[size-and-state-budget](docs/size-and-state-budget.md), [e2e-shape](docs/e2e-shape.md),
|
|
41
|
+
[spec-quality](docs/spec-quality.md) and [lint-escape-hatch](docs/lint-escape-hatch.md). `timer-needs-effect-cleanup` also accepts a timer whose enclosing function returns a cleanup clearing the same handle (5.1.2). New in 5.1: [lists](docs/lists.md), [hygiene](docs/hygiene.md), [formatting](docs/formatting.md) and [type-safety](docs/type-safety.md), plus rules added to next-conventions, client-boundary, native-controls and transport.
|
|
42
|
+
|
|
43
|
+
Older laws cover comments, file layout, icons and vendor ownership, landmarks, loading states,
|
|
44
|
+
naming, props, served locales, component splits, design tokens, type safety, typography, and
|
|
30
45
|
class-name ownership. Reusable class names belong in a colocated `classNames.ts` module and should
|
|
31
46
|
be composed with HeroUI `cn` using one utility token per argument.
|
|
32
47
|
|
|
@@ -37,9 +52,12 @@ shapes and never a data status (`no-data-status-shape`), and a block renders eac
|
|
|
37
52
|
loading/forbidden/error/empty through `SlotView` (`slot-status-through-slotview`). Reference tree:
|
|
38
53
|
`.claude/examples/shape-slot`.
|
|
39
54
|
|
|
55
|
+
The one locale pattern everywhere is `next-intl`, a `[locale]` segment, default `vi`,
|
|
56
|
+
`localePrefix: "as-needed"`, and `proxy.ts` instead of `middleware.ts`.
|
|
57
|
+
|
|
40
58
|
Grammar is a business-neutral HeroUI-backed component package. Its components accept ordinary
|
|
41
59
|
typed React props and children.
|
|
42
60
|
|
|
43
61
|
## Requirements
|
|
44
62
|
|
|
45
|
-
ESLint 9+ (flat config) and Node.js 20.9+.
|
|
63
|
+
ESLint 9+ (flat config), `eslint-plugin-react-hooks` 7+, and Node.js 20.9+.
|
package/brand-values.mjs
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule that holds `brand-values.md` (HFS R61 `FE_STYLE_TOKEN_ONLY`, the TypeScript half; the CSS
|
|
3
|
+
* half belongs to `@starci/stylelint-canon`).
|
|
4
|
+
*
|
|
5
|
+
* ONE PLACE FOR A COLOUR. Colour values exist in `modules/brand/brand.css`, set only on the tokens
|
|
6
|
+
* the grammar declares, with a light and a dark value, and checked against the brand record. A hex
|
|
7
|
+
* in a component is a colour that has no dark value, cannot be re-branded, and drifts from the
|
|
8
|
+
* record: one repository carried three different ways to override the brand, and another 4.3k lines
|
|
9
|
+
* of hand-made CSS with a private token namespace.
|
|
10
|
+
*
|
|
11
|
+
* LENGTHS FOLLOW. A pixel length in TypeScript (`"12px"`, `{ width: 12 }` in a `style` prop) is a
|
|
12
|
+
* spacing decision that skipped the grammar's scale, so it does not move with density, zoom or a
|
|
13
|
+
* breakpoint. Spacing and sizing come from the grammar's utility tokens.
|
|
14
|
+
*
|
|
15
|
+
* WHAT IT DELIBERATELY LEAVES ALONE: a fragment identifier (`href="#top"`, `id`, `htmlFor`), a
|
|
16
|
+
* `data:` URI, a component's `color="primary"` prop (that is a variant name, not a colour), and
|
|
17
|
+
* Tailwind arbitrary values (`w-[12px]`), which `no-arbitrary-value` already reports - two rules
|
|
18
|
+
* for one span would be two findings for one fix.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { isSpecFile } from "./lib/scope.mjs"
|
|
22
|
+
import { isContentFile } from "./comments.mjs"
|
|
23
|
+
|
|
24
|
+
/** A hex colour: 3, 4, 6 or 8 digits, not a longer word and not part of an identifier. */
|
|
25
|
+
const HEX_COLOR = /(?<![\w&])#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})(?![\w-])/
|
|
26
|
+
|
|
27
|
+
/** A functional colour notation. */
|
|
28
|
+
const COLOR_FUNCTION = /\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color-mix)\(/i
|
|
29
|
+
|
|
30
|
+
/** A pixel length, not inside an arbitrary-value bracket and not part of a longer number or word. */
|
|
31
|
+
const PIXEL_LENGTH = /(?<![\w.#[-])-?(?:\d+\.?\d*|\.\d+)px\b/
|
|
32
|
+
|
|
33
|
+
/** Attributes whose value is a fragment or an id, where a `#abc` is not a colour. */
|
|
34
|
+
const NON_COLOR_ATTRS = new Set(["href", "id", "htmlFor", "to", "name", "aria-controls", "aria-labelledby", "aria-describedby"])
|
|
35
|
+
|
|
36
|
+
/** SVG paint attributes: a named colour here is a raw brand value. */
|
|
37
|
+
const PAINT_ATTRS = new Set(["fill", "stroke", "stopColor", "floodColor", "lightingColor"])
|
|
38
|
+
|
|
39
|
+
/** Style properties that take a colour. */
|
|
40
|
+
const COLOR_KEYS = new Set([
|
|
41
|
+
"color",
|
|
42
|
+
"background",
|
|
43
|
+
"backgroundColor",
|
|
44
|
+
"borderColor",
|
|
45
|
+
"outlineColor",
|
|
46
|
+
"fill",
|
|
47
|
+
"stroke",
|
|
48
|
+
"caretColor",
|
|
49
|
+
"accentColor",
|
|
50
|
+
"textDecorationColor",
|
|
51
|
+
])
|
|
52
|
+
|
|
53
|
+
/** Style properties that take a length. */
|
|
54
|
+
const LENGTH_KEYS = new Set([
|
|
55
|
+
"width",
|
|
56
|
+
"height",
|
|
57
|
+
"minWidth",
|
|
58
|
+
"maxWidth",
|
|
59
|
+
"minHeight",
|
|
60
|
+
"maxHeight",
|
|
61
|
+
"top",
|
|
62
|
+
"right",
|
|
63
|
+
"bottom",
|
|
64
|
+
"left",
|
|
65
|
+
"gap",
|
|
66
|
+
"margin",
|
|
67
|
+
"marginTop",
|
|
68
|
+
"marginRight",
|
|
69
|
+
"marginBottom",
|
|
70
|
+
"marginLeft",
|
|
71
|
+
"padding",
|
|
72
|
+
"paddingTop",
|
|
73
|
+
"paddingRight",
|
|
74
|
+
"paddingBottom",
|
|
75
|
+
"paddingLeft",
|
|
76
|
+
"fontSize",
|
|
77
|
+
"borderRadius",
|
|
78
|
+
"letterSpacing",
|
|
79
|
+
])
|
|
80
|
+
|
|
81
|
+
/** Values that carry no raw colour: a token reference or a keyword. */
|
|
82
|
+
const NEUTRAL_COLOR = /^(?:var\(|inherit$|initial$|unset$|revert$|currentcolor$|transparent$|none$)/i
|
|
83
|
+
|
|
84
|
+
/** Static string of a literal or an expression-free template, else null. */
|
|
85
|
+
const staticString = (node) => {
|
|
86
|
+
if (!node) return null
|
|
87
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value
|
|
88
|
+
if (node.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0]?.value.cooked ?? ""
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** No raw colour, hex or pixel value outside `brand.css`. */
|
|
93
|
+
export const noRawBrandValue = {
|
|
94
|
+
meta: {
|
|
95
|
+
type: "problem",
|
|
96
|
+
docs: { description: "No hex, colour function, named paint colour or px length in TypeScript; tokens only." },
|
|
97
|
+
schema: [],
|
|
98
|
+
messages: {
|
|
99
|
+
color:
|
|
100
|
+
"A raw colour value (`{{value}}`). Colour values exist only in `modules/brand/brand.css`, on the tokens the grammar declares, with a light and a dark value. Here it has no dark value, cannot be re-branded and drifts from the brand record. Use a grammar token class or variable.",
|
|
101
|
+
length:
|
|
102
|
+
"A raw pixel length (`{{value}}`). Spacing and size come from the grammar's scale so they move with density, zoom and breakpoint. Use a grammar utility token.",
|
|
103
|
+
named:
|
|
104
|
+
"`{{key}}: {{value}}` puts a named colour in a style. Colour is a token, not a word: use a grammar token (`var(--...)` or a token class).",
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
create(context) {
|
|
108
|
+
const filename = context.filename || context.getFilename()
|
|
109
|
+
if (isSpecFile(filename) || isContentFile(filename)) return {}
|
|
110
|
+
const source = context.sourceCode || context.getSourceCode()
|
|
111
|
+
/** Values already reported through an owner, so a string reports once. */
|
|
112
|
+
const claimed = new WeakSet()
|
|
113
|
+
|
|
114
|
+
/** True when a node sits inside a JSX `style={...}` attribute. */
|
|
115
|
+
const inStyleAttribute = (node) =>
|
|
116
|
+
source
|
|
117
|
+
.getAncestors(node)
|
|
118
|
+
.some((ancestor) => ancestor.type === "JSXAttribute" && ancestor.name.type === "JSXIdentifier" && ancestor.name.name === "style")
|
|
119
|
+
|
|
120
|
+
/** Reports the first raw value in a string, once. */
|
|
121
|
+
const scan = (node, text) => {
|
|
122
|
+
if (claimed.has(node) || typeof text !== "string" || text.startsWith("data:")) return
|
|
123
|
+
const parent = node.parent
|
|
124
|
+
if (parent && parent.type === "JSXAttribute" && NON_COLOR_ATTRS.has(parent.name.name)) return
|
|
125
|
+
if (parent && parent.type === "JSXExpressionContainer" && parent.parent.type === "JSXAttribute") {
|
|
126
|
+
if (NON_COLOR_ATTRS.has(parent.parent.name.name)) return
|
|
127
|
+
}
|
|
128
|
+
if (parent && (parent.type === "ImportDeclaration" || parent.type === "ExportAllDeclaration" || parent.type === "ExportNamedDeclaration")) return
|
|
129
|
+
const color = HEX_COLOR.exec(text) || COLOR_FUNCTION.exec(text)
|
|
130
|
+
if (color) return context.report({ node, messageId: "color", data: { value: color[0].replace(/\($/, "(...)") } })
|
|
131
|
+
const length = PIXEL_LENGTH.exec(text)
|
|
132
|
+
if (length) context.report({ node, messageId: "length", data: { value: length[0] } })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
Literal(node) {
|
|
137
|
+
scan(node, node.value)
|
|
138
|
+
},
|
|
139
|
+
TemplateElement(node) {
|
|
140
|
+
scan(node, node.value && node.value.cooked)
|
|
141
|
+
},
|
|
142
|
+
JSXAttribute(node) {
|
|
143
|
+
if (node.name.type !== "JSXIdentifier" || !PAINT_ATTRS.has(node.name.name)) return
|
|
144
|
+
const value = node.value && node.value.type === "JSXExpressionContainer" ? node.value.expression : node.value
|
|
145
|
+
const text = staticString(value)
|
|
146
|
+
if (text === null || NEUTRAL_COLOR.test(text) || HEX_COLOR.test(text) || COLOR_FUNCTION.test(text)) return
|
|
147
|
+
claimed.add(value)
|
|
148
|
+
context.report({ node, messageId: "named", data: { key: node.name.name, value: text } })
|
|
149
|
+
},
|
|
150
|
+
Property(node) {
|
|
151
|
+
if (node.computed || node.key.type !== "Identifier" || !inStyleAttribute(node)) return
|
|
152
|
+
const key = node.key.name
|
|
153
|
+
if (COLOR_KEYS.has(key)) {
|
|
154
|
+
const text = staticString(node.value)
|
|
155
|
+
if (text === null || NEUTRAL_COLOR.test(text) || HEX_COLOR.test(text) || COLOR_FUNCTION.test(text)) return
|
|
156
|
+
claimed.add(node.value)
|
|
157
|
+
context.report({ node, messageId: "named", data: { key, value: text } })
|
|
158
|
+
} else if (LENGTH_KEYS.has(key) && node.value.type === "Literal" && typeof node.value.value === "number") {
|
|
159
|
+
if (node.value.value === 0) return
|
|
160
|
+
claimed.add(node.value)
|
|
161
|
+
context.report({ node: node.value, messageId: "length", data: { value: `${key}: ${node.value.value}` } })
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The rules this law contributes to the plugin. */
|
|
169
|
+
export const rules = {
|
|
170
|
+
"no-raw-brand-value": noRawBrandValue,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Every rule is an error. */
|
|
174
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
package/class-names.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* classNames files own the utility-token composition.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
import { normalizePath } from "./lib/path.mjs"
|
|
7
7
|
const isComponentSource = (filename) => normalizePath(filename).includes("/src/components/")
|
|
8
8
|
const isClassNamesFile = (filename) => /\/classNames\.tsx?$/.test(normalizePath(filename))
|
|
9
9
|
const isClassAttribute = (node) => node.type === "JSXAttribute" && ["className", "class"].includes(node.name?.name)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule that holds `client-boundary.md` (HFS R55, `FE_CLIENT_BOUNDARY`).
|
|
3
|
+
*
|
|
4
|
+
* SERVER FIRST. A route file is a server component; `"use client"` is a cost the app pays where a
|
|
5
|
+
* reader interacts, and it is paid once, at the top of the interactive subtree, not sprinkled
|
|
6
|
+
* wherever a hook happened to be needed. The measured alternative: 155 client files in one app,
|
|
7
|
+
* a client layout that shipped a 132 KB message catalog to every page, against 3 client files in a
|
|
8
|
+
* sibling app that did the same job server-first.
|
|
9
|
+
*
|
|
10
|
+
* The directive is allowed in exactly the places the framework or the interaction requires:
|
|
11
|
+
* - the `index.tsx` of an interactive block (or overlay), which is the connected half;
|
|
12
|
+
* - a leaf, whose interactivity is intrinsic (a menu, a tooltip trigger);
|
|
13
|
+
* - `error.tsx` and `global-error.tsx`, which Next requires to be client components.
|
|
14
|
+
* Everything below a client block is already on the client and needs no directive; everything
|
|
15
|
+
* above it is a server component and must not have one.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { PACKAGE_TIERS, ROUTE_SLOTS, isSpecFile, stem } from "./lib/scope.mjs"
|
|
19
|
+
import { normalizePath } from "./lib/path.mjs"
|
|
20
|
+
|
|
21
|
+
/** The framework's own client-only files. */
|
|
22
|
+
const FRAMEWORK_CLIENT = new Set(["error", "global-error"])
|
|
23
|
+
|
|
24
|
+
/** True when the program opens with the `"use client"` directive. */
|
|
25
|
+
const clientDirective = (program) =>
|
|
26
|
+
program.body.find(
|
|
27
|
+
(statement) => statement.type === "ExpressionStatement" && statement.directive === "use client",
|
|
28
|
+
) ?? null
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The component tiers of a workspace package sit directly under `src/` (grammar tier names,
|
|
32
|
+
* `slots.yaml` `fe.package.ui`: composites, branches, leaves), with no `components/` folder above
|
|
33
|
+
* them. A package has no data layer, so its interactive tiers, branches and leaves, are where the
|
|
34
|
+
* directive is legitimate; a composite is presentation and stays a server-safe component.
|
|
35
|
+
*/
|
|
36
|
+
const PACKAGE_CLIENT_TIERS = PACKAGE_TIERS.filter((tier) => tier !== "composites")
|
|
37
|
+
const PACKAGE_TIER = new RegExp(`/packages/[^/]+/src/(?:${PACKAGE_CLIENT_TIERS.join("|")})/`)
|
|
38
|
+
|
|
39
|
+
/** Where the directive is a legitimate boundary. */
|
|
40
|
+
const isBoundary = (file) =>
|
|
41
|
+
FRAMEWORK_CLIENT.has(stem(file)) ||
|
|
42
|
+
(/\/components\/blocks\//.test(file) && stem(file) === "index") ||
|
|
43
|
+
(/\/features\/overlays\//.test(file) && stem(file) === "index") ||
|
|
44
|
+
/\/components\/leaves\//.test(file) ||
|
|
45
|
+
PACKAGE_TIER.test(file)
|
|
46
|
+
|
|
47
|
+
/** The slot a file fills in the route tree, when it fills one. */
|
|
48
|
+
const slotOf = (file) => {
|
|
49
|
+
const name = stem(file)
|
|
50
|
+
if (/\/app\//.test(file) && ROUTE_SLOTS.includes(name)) return name
|
|
51
|
+
if (/\/features\/pages\//.test(file)) return "page owner"
|
|
52
|
+
if (/\/features\/layouts\//.test(file)) return "layout owner"
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `"use client"` only at an interaction boundary; pages and layouts stay server components. */
|
|
57
|
+
export const useClientOnlyAtBoundary = {
|
|
58
|
+
meta: {
|
|
59
|
+
type: "problem",
|
|
60
|
+
docs: { description: "`\"use client\"` appears only at a block index, a leaf, or a Next error boundary." },
|
|
61
|
+
schema: [],
|
|
62
|
+
messages: {
|
|
63
|
+
slot:
|
|
64
|
+
"`\"use client\"` on a {{slot}}. Routes, pages and layouts are server components: making one a client component ships its whole subtree, including the message catalog, to every reader and stops it reading data on the server. Move the interaction into a block below it and put the directive on that block's `index.tsx`.",
|
|
65
|
+
elsewhere:
|
|
66
|
+
"`\"use client\"` here is not at an interaction boundary. The directive belongs on the `index.tsx` of the interactive block (or overlay), on a leaf whose interaction is intrinsic, or on `error.tsx`/`global-error.tsx`. Below a client block the file is already client; above one it must stay a server component.",
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
create(context) {
|
|
70
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
71
|
+
if (isSpecFile(filename) || isBoundary(filename)) return {}
|
|
72
|
+
const slot = slotOf(filename)
|
|
73
|
+
return {
|
|
74
|
+
Program(program) {
|
|
75
|
+
const directive = clientDirective(program)
|
|
76
|
+
if (!directive) return
|
|
77
|
+
if (slot) context.report({ node: directive, messageId: "slot", data: { slot } })
|
|
78
|
+
else context.report({ node: directive, messageId: "elsewhere" })
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// -- FE-CLIENT-2 -----------------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
/** Node built-ins a browser bundle cannot carry. */
|
|
87
|
+
const NODE_BUILTINS = /^(?:node:.+|fs|fs\/promises|path|os|net|tls|http|https|http2|child_process|cluster|crypto|stream|zlib|dns|worker_threads)$/
|
|
88
|
+
|
|
89
|
+
/** A server-only source: the marker package, the request-scoped Next APIs, a Node built-in, a server reader. */
|
|
90
|
+
const serverOnlyReason = (source) => {
|
|
91
|
+
if (source === "server-only") return "`server-only`"
|
|
92
|
+
if (source === "next/headers") return "`next/headers`"
|
|
93
|
+
if (source === "next/server") return "`next/server`"
|
|
94
|
+
if (NODE_BUILTINS.test(source)) return `\`${source}\``
|
|
95
|
+
if (/(?:^|\/)modules\/api\/(?:.+\/)?read-[^/]+$/.test(source)) return `the server reader \`${source}\``
|
|
96
|
+
return null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A client component imports nothing that only exists on the server. */
|
|
100
|
+
export const clientNoServerImport = {
|
|
101
|
+
meta: {
|
|
102
|
+
type: "problem",
|
|
103
|
+
docs: { description: "A `\"use client\"` file does not import `server-only`, `next/headers`, a Node built-in or a server reader." },
|
|
104
|
+
schema: [],
|
|
105
|
+
messages: {
|
|
106
|
+
server:
|
|
107
|
+
"This client component imports {{what}}, which exists only on the server. The build either fails or, worse, pulls server code and its secrets toward the browser bundle. Read the data in a server component or a server reader and pass the result down as props, or call the app client through SWR.",
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
create(context) {
|
|
111
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
112
|
+
if (isSpecFile(file)) return {}
|
|
113
|
+
let client = false
|
|
114
|
+
return {
|
|
115
|
+
Program(program) {
|
|
116
|
+
client = clientDirective(program) !== null
|
|
117
|
+
},
|
|
118
|
+
ImportDeclaration(node) {
|
|
119
|
+
if (!client || node.importKind === "type") return
|
|
120
|
+
const what = serverOnlyReason(String(node.source.value))
|
|
121
|
+
if (what) context.report({ node, messageId: "server", data: { what } })
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// -- FE-CLIENT-3 -----------------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
/** Web storage exists only in the browser and only under `modules/`, which guards it. */
|
|
130
|
+
const STORAGE = new Set(["localStorage", "sessionStorage"])
|
|
131
|
+
|
|
132
|
+
/** Web storage is read and written only inside `modules/`. */
|
|
133
|
+
export const webStorageOnlyInModules = {
|
|
134
|
+
meta: {
|
|
135
|
+
type: "problem",
|
|
136
|
+
docs: { description: "`localStorage` and `sessionStorage` are touched only inside `modules/`." },
|
|
137
|
+
schema: [],
|
|
138
|
+
messages: {
|
|
139
|
+
storage:
|
|
140
|
+
"`{{name}}` here, outside `modules/`. Storage does not exist on the server and throws in private windows and when the quota is full, so a block that reads it during render breaks server rendering and hydrates differently from the HTML it received. Put the read and write behind a hook or a module that guards both (`typeof window`, try/catch) and let the block call that.",
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
create(context) {
|
|
144
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
145
|
+
if (isSpecFile(file) || !file.includes("/src/") || /\/modules\//.test(file)) return {}
|
|
146
|
+
return {
|
|
147
|
+
MemberExpression(node) {
|
|
148
|
+
if (node.computed || node.property.type !== "Identifier" || !STORAGE.has(node.property.name)) return
|
|
149
|
+
if (node.object.type !== "Identifier" || (node.object.name !== "window" && node.object.name !== "globalThis")) return
|
|
150
|
+
context.report({ node, messageId: "storage", data: { name: node.property.name } })
|
|
151
|
+
},
|
|
152
|
+
Identifier(node) {
|
|
153
|
+
if (!STORAGE.has(node.name)) return
|
|
154
|
+
const parent = node.parent
|
|
155
|
+
if (parent.type === "MemberExpression" && parent.property === node) return
|
|
156
|
+
if (parent.type === "Property" && parent.key === node && !parent.shorthand) return
|
|
157
|
+
if (parent.type === "TSPropertySignature" || parent.type === "TSTypeAnnotation") return
|
|
158
|
+
context.report({ node, messageId: "storage", data: { name: node.name } })
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// -- FE-CLIENT-4 -----------------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
/** Raw HTML is set only on a `<script>`, where the framework has no other way to inline JSON-LD or the theme bootstrap. */
|
|
167
|
+
export const noDangerousHtml = {
|
|
168
|
+
meta: {
|
|
169
|
+
type: "problem",
|
|
170
|
+
docs: { description: "`dangerouslySetInnerHTML` only on `<script>`; never on a visible element." },
|
|
171
|
+
schema: [],
|
|
172
|
+
messages: {
|
|
173
|
+
html:
|
|
174
|
+
"`dangerouslySetInnerHTML` on `<{{tag}}>` writes a string into the page as markup, so any text from a user, a provider or a message catalogue becomes script. Render the content as elements. The one legitimate use is a `<script>` carrying JSON-LD or the theme bootstrap, from a constant this app wrote.",
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
create(context) {
|
|
178
|
+
if (isSpecFile(context.filename || context.getFilename())) return {}
|
|
179
|
+
return {
|
|
180
|
+
JSXAttribute(node) {
|
|
181
|
+
if (node.name.type !== "JSXIdentifier" || node.name.name !== "dangerouslySetInnerHTML") return
|
|
182
|
+
const opening = node.parent
|
|
183
|
+
const tag = opening.name.type === "JSXIdentifier" ? opening.name.name : "element"
|
|
184
|
+
if (tag !== "script") context.report({ node, messageId: "html", data: { tag } })
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The rules this law contributes to the plugin. */
|
|
191
|
+
export const rules = {
|
|
192
|
+
"use-client-only-at-boundary": useClientOnlyAtBoundary,
|
|
193
|
+
"client-no-server-import": clientNoServerImport,
|
|
194
|
+
"web-storage-only-in-modules": webStorageOnlyInModules,
|
|
195
|
+
"no-dangerous-html": noDangerousHtml,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Every rule is an error. */
|
|
199
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|