@starci/eslint-canon-fe 1.4.5 → 3.0.0
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/README.md +38 -86
- package/class-names.mjs +145 -0
- package/file-layout.mjs +6 -7
- package/grammar-boundary.mjs +16 -0
- package/index.mjs +19 -4
- package/landmark.mjs +29 -242
- package/package.json +2 -4
- package/props-and-slots.mjs +148 -452
- package/the-split.mjs +17 -10
- package/vendor-boundary.mjs +24 -595
- package/contract.mjs +0 -1387
- package/contracts.ts +0 -312
- package/props.ts +0 -145
package/README.md
CHANGED
|
@@ -1,86 +1,38 @@
|
|
|
1
|
-
# @starci/eslint-canon-fe
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
Prefer to wire it by hand? Every piece is exported: `rules`, `recommended`, `linterOptions`,
|
|
41
|
-
`ruleOwners`, `audits`, `auditOwners`, `lawOwners`.
|
|
42
|
-
|
|
43
|
-
## What it actually catches
|
|
44
|
-
|
|
45
|
-
A sample, not the list:
|
|
46
|
-
|
|
47
|
-
| Rule | What it stops |
|
|
48
|
-
|---|---|
|
|
49
|
-
| `no-literal-structural-class` | Layout classes typed straight onto an element instead of named in the contract registry |
|
|
50
|
-
| `no-unknown-contract-key` | A structure key that no registry entry declares |
|
|
51
|
-
| `presentational-purity` | A drawing component that fetches, so it can never be rendered from a test |
|
|
52
|
-
| `connected-block-has-presentational-twin` | A block wired to data with no pure half to render |
|
|
53
|
-
| `no-vendor-icon-outside-icon-leaf` | A vendor glyph imported anywhere but the one leaf that wraps it |
|
|
54
|
-
| `no-arbitrary-value` · `no-fractional-step` | Values off the spacing scale, invented per call site |
|
|
55
|
-
| `no-resting-twin-component` | A hand-kept skeleton tree that drifts from the thing it stands in for |
|
|
56
|
-
| `no-inline-lint-config` | A disable comment switching the architecture off for one line |
|
|
57
|
-
| `require-export-jsdoc` | An exported symbol with no stated reason to exist |
|
|
58
|
-
| `no-second-language-in-source` | Copy hardcoded where the translation layer should own it |
|
|
59
|
-
|
|
60
|
-
Every rule names the law that declares it — `ruleOwners` maps rule name to law, so a failing build
|
|
61
|
-
line leads straight to the document that explains why.
|
|
62
|
-
|
|
63
|
-
## Why the rules are all errors
|
|
64
|
-
|
|
65
|
-
Existing debt gets fixed before adoption. Lowering architecture to `warn` teaches every later author
|
|
66
|
-
that the boundary is optional, and a boundary everyone learns is optional is not a boundary. There
|
|
67
|
-
is no staged rollout mode, on purpose.
|
|
68
|
-
|
|
69
|
-
## Companion
|
|
70
|
-
|
|
71
|
-
- **[@starci/eslint-canon-be](https://www.npmjs.com/package/@starci/eslint-canon-be)** — the
|
|
72
|
-
back-end half: 37 rules from 15 laws covering CQRS, exceptions, transport, observability and
|
|
73
|
-
end-to-end flows.
|
|
74
|
-
|
|
75
|
-
## Where the laws live
|
|
76
|
-
|
|
77
|
-
Each rule is the enforceable half of a written law. The prose — why the rule exists, what it
|
|
78
|
-
refuses, which cases sit just outside it — is published openly at
|
|
79
|
-
[starci183/starci-claude-skills](https://github.com/starci183/starci-claude-skills).
|
|
80
|
-
|
|
81
|
-
A rule that cannot be pointed at in real code is a proposal, not a law. Everything here is
|
|
82
|
-
pointed at.
|
|
83
|
-
|
|
84
|
-
## Requirements
|
|
85
|
-
|
|
86
|
-
ESLint 9+ (flat config), Node 20.9+.
|
|
1
|
+
# @starci/eslint-canon-fe
|
|
2
|
+
|
|
3
|
+
The StarCi front-end lint canon for traditional React and TypeScript applications. It keeps
|
|
4
|
+
component ownership, accessibility, loading behavior, vendor boundaries, translations, naming,
|
|
5
|
+
and colocated class-name composition consistent without prescribing a custom rendering protocol.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm i -D @starci/eslint-canon-fe
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
import starciFe, { recommended, linterOptions, starciFeConfig } from "@starci/eslint-canon-fe"
|
|
13
|
+
|
|
14
|
+
export default [starciFeConfig({
|
|
15
|
+
layout: "single-app",
|
|
16
|
+
plugin: starciFe,
|
|
17
|
+
recommended,
|
|
18
|
+
linterOptions,
|
|
19
|
+
})]
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The `single-app` layout governs `src/**`; `monorepo` governs shared package and app source trees.
|
|
23
|
+
The package publishes the plugin, recommended levels, repository audits, and layout helper from
|
|
24
|
+
the root entry point.
|
|
25
|
+
|
|
26
|
+
## Rules
|
|
27
|
+
|
|
28
|
+
Rules cover comments, file layout, icons and vendor ownership, landmarks, loading states, naming,
|
|
29
|
+
props, served locales, component splits, design tokens, translations, type safety, typography, and
|
|
30
|
+
class-name ownership. Reusable class names belong in a colocated `classNames.ts` module and should
|
|
31
|
+
be composed with HeroUI `cn` using one utility token per argument.
|
|
32
|
+
|
|
33
|
+
Grammar is a business-neutral HeroUI-backed component package. Its components accept ordinary
|
|
34
|
+
typed React props and children.
|
|
35
|
+
|
|
36
|
+
## Requirements
|
|
37
|
+
|
|
38
|
+
ESLint 9+ (flat config) and Node.js 20.9+.
|
package/class-names.mjs
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The classNames law: component markup consumes named styling declarations, while colocated
|
|
3
|
+
* classNames files own the utility-token composition.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
|
|
7
|
+
const isComponentSource = (filename) => normalizePath(filename).includes("/src/components/")
|
|
8
|
+
const isClassNamesFile = (filename) => /\/classNames\.tsx?$/.test(normalizePath(filename))
|
|
9
|
+
const isClassAttribute = (node) => node.type === "JSXAttribute" && ["className", "class"].includes(node.name?.name)
|
|
10
|
+
const isCn = (node) => node?.type === "Identifier" && node.name === "cn"
|
|
11
|
+
|
|
12
|
+
const importedClassNames = (context) => {
|
|
13
|
+
const imports = new Set()
|
|
14
|
+
for (const statement of context.getSourceCode().ast.body) {
|
|
15
|
+
if (statement.type !== "ImportDeclaration" || statement.source.value !== "./classNames") continue
|
|
16
|
+
for (const specifier of statement.specifiers) imports.add(specifier.local.name)
|
|
17
|
+
}
|
|
18
|
+
return imports
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const isExportedOwner = (node) => {
|
|
22
|
+
let current = node
|
|
23
|
+
while (current) {
|
|
24
|
+
if (["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"].includes(current.type)) {
|
|
25
|
+
current = current.parent
|
|
26
|
+
if (current?.type === "VariableDeclarator") current = current.parent
|
|
27
|
+
if (current?.type === "VariableDeclaration") current = current.parent
|
|
28
|
+
return ["ExportNamedDeclaration", "ExportDefaultDeclaration"].includes(current?.type)
|
|
29
|
+
}
|
|
30
|
+
if (current.type === "VariableDeclarator") {
|
|
31
|
+
current = current.parent?.parent
|
|
32
|
+
return ["ExportNamedDeclaration", "ExportDefaultDeclaration"].includes(current?.type)
|
|
33
|
+
}
|
|
34
|
+
current = current.parent
|
|
35
|
+
}
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const visitTokenLeaves = (node, report) => {
|
|
40
|
+
if (!node) return
|
|
41
|
+
if (node.type === "ArrayExpression") {
|
|
42
|
+
report(node, "array")
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
if (node.type === "Literal" && typeof node.value === "string") {
|
|
46
|
+
if (/\s/.test(node.value) || /\d+,\d+/.test(node.value)) report(node, "token")
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
if (node.type === "ConditionalExpression" || node.type === "LogicalExpression") {
|
|
50
|
+
visitTokenLeaves(node.consequent ?? node.left, report)
|
|
51
|
+
visitTokenLeaves(node.alternate ?? node.right, report)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const isEmptyClassName = (node) => node?.type === "Literal" && node.value === null || node?.type === "Identifier" && node.name === "undefined"
|
|
56
|
+
|
|
57
|
+
const usesImportedClassName = (node, imported) => {
|
|
58
|
+
if (!node) return false
|
|
59
|
+
if (node.type === "Identifier") return imported.has(node.name) || node.name === "undefined"
|
|
60
|
+
if (isEmptyClassName(node)) return true
|
|
61
|
+
if (node.type === "CallExpression") return node.callee.type === "Identifier" && imported.has(node.callee.name)
|
|
62
|
+
if (node.type === "MemberExpression") return node.object.type === "Identifier" && imported.has(node.object.name)
|
|
63
|
+
if (node.type === "ConditionalExpression") return usesImportedClassName(node.consequent, imported) && usesImportedClassName(node.alternate, imported)
|
|
64
|
+
if (node.type === "LogicalExpression") {
|
|
65
|
+
if (node.operator === "&&") return usesImportedClassName(node.right, imported)
|
|
66
|
+
return usesImportedClassName(node.left, imported) && usesImportedClassName(node.right, imported)
|
|
67
|
+
}
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Report className values written directly in component JSX. */
|
|
72
|
+
export const noInlineClassName = {
|
|
73
|
+
meta: {
|
|
74
|
+
type: "problem",
|
|
75
|
+
docs: { description: "Component JSX must import a named className declaration." },
|
|
76
|
+
schema: [],
|
|
77
|
+
messages: { inline: "Move this className value to an imported colocated classNames.ts export." },
|
|
78
|
+
},
|
|
79
|
+
create(context) {
|
|
80
|
+
if (!isComponentSource(context.filename || context.getFilename())) return {}
|
|
81
|
+
const imported = importedClassNames(context)
|
|
82
|
+
return {
|
|
83
|
+
JSXAttribute(node) {
|
|
84
|
+
if (!isClassAttribute(node) || !node.value) return
|
|
85
|
+
const expression = node.value.type === "JSXExpressionContainer" ? node.value.expression : node.value
|
|
86
|
+
if (!usesImportedClassName(expression, imported)) context.report({ node, messageId: "inline" })
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Require every classNames declaration to use the colocated classNames.ts convention. */
|
|
93
|
+
export const classNamesInColocatedFile = {
|
|
94
|
+
meta: {
|
|
95
|
+
type: "problem",
|
|
96
|
+
docs: { description: "Reusable className declarations belong in colocated classNames.ts files." },
|
|
97
|
+
schema: [],
|
|
98
|
+
messages: { misplaced: "Put reusable className declarations in a colocated classNames.ts file." },
|
|
99
|
+
},
|
|
100
|
+
create(context) {
|
|
101
|
+
const filename = context.filename || context.getFilename()
|
|
102
|
+
if (!isComponentSource(filename)) return {}
|
|
103
|
+
return {
|
|
104
|
+
CallExpression(node) {
|
|
105
|
+
if (!isCn(node.callee) || isClassNamesFile(filename)) return
|
|
106
|
+
context.report({ node, messageId: "misplaced" })
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Require variadic cn utility tokens: no arrays, whitespace-packed values, or fractional commas. */
|
|
113
|
+
export const cnArgumentsAreSingleTokens = {
|
|
114
|
+
meta: {
|
|
115
|
+
type: "problem",
|
|
116
|
+
docs: { description: "Each cn argument is one valid utility token." },
|
|
117
|
+
schema: [],
|
|
118
|
+
messages: {
|
|
119
|
+
array: "Pass utility tokens to cn as variadic arguments, not an array.",
|
|
120
|
+
token: "Each cn argument must be one utility token without whitespace or malformed fractional commas.",
|
|
121
|
+
unexported: "Export every className declaration or function that produces a cn value.",
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
create(context) {
|
|
125
|
+
const filename = context.filename || context.getFilename()
|
|
126
|
+
if (!isClassNamesFile(filename)) return {}
|
|
127
|
+
return {
|
|
128
|
+
CallExpression(node) {
|
|
129
|
+
if (!isCn(node.callee)) return
|
|
130
|
+
if (!isExportedOwner(node)) context.report({ node, messageId: "unexported" })
|
|
131
|
+
for (const argument of node.arguments) visitTokenLeaves(argument, (reportedNode, messageId) => context.report({ node: reportedNode, messageId }))
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Every rule this law publishes. */
|
|
138
|
+
export const rules = {
|
|
139
|
+
"no-inline-class-name": noInlineClassName,
|
|
140
|
+
"class-names-in-colocated-file": classNamesInColocatedFile,
|
|
141
|
+
"cn-arguments-are-single-tokens": cnArgumentsAreSingleTokens,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Recommended severity for the classNames law. */
|
|
145
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
package/file-layout.mjs
CHANGED
|
@@ -22,7 +22,7 @@ const TWO_FILE_TIERS = /\/src\/components\/(pages|layouts)\/([^/]+)\/(.+)$/
|
|
|
22
22
|
const TWO_FILE_TIERS_WITH_CATEGORY = /\/src\/components\/(overlays)\/[^/]+\/([^/]+)\/(.+)$/
|
|
23
23
|
|
|
24
24
|
/** The two halves, and the twin test of each, are the whole of what a surface folder may hold. */
|
|
25
|
-
const ALLOWED_IN_SURFACE_FOLDER = /^(?:component|index)(?:\.spec)?\.tsx?$/
|
|
25
|
+
const ALLOWED_IN_SURFACE_FOLDER = /^(?:component|index|classNames)(?:\.spec)?\.tsx?$/
|
|
26
26
|
|
|
27
27
|
/** Folders that are not component code, whatever they are nested inside. */
|
|
28
28
|
const NON_COMPONENT_FOLDERS = /\/src\/components\/.*\/(constants|utils|types|hooks)\//
|
|
@@ -36,7 +36,7 @@ const surfaceFolder = (filename) => {
|
|
|
36
36
|
|
|
37
37
|
// -- FILE-2 --------------------------------------------------------------------------------------
|
|
38
38
|
|
|
39
|
-
/** A page, layout or overlay folder holds its two halves and
|
|
39
|
+
/** A page, layout or overlay folder holds its two halves and its colocated class-name module. */
|
|
40
40
|
export const surfaceFolderTwoFilesOnly = {
|
|
41
41
|
meta: {
|
|
42
42
|
type: "problem",
|
|
@@ -44,7 +44,7 @@ export const surfaceFolderTwoFilesOnly = {
|
|
|
44
44
|
schema: [],
|
|
45
45
|
messages: {
|
|
46
46
|
extra:
|
|
47
|
-
"`{{tier}}/{{name}}/` contains `{{rest}}` - a surface folder holds
|
|
47
|
+
"`{{tier}}/{{name}}/` contains `{{rest}}` - a surface folder holds `component.tsx`, `index.tsx`, and an optional `classNames.ts`. Whatever else this is has a real home: a component of its own goes to `blocks/<category>/`, a closed arrangement to `composites/`, a fetch to `hooks/`, a pure helper to `modules/utils/`, a shape to `modules/types/`, copy or a config map to `resources/`.",
|
|
48
48
|
},
|
|
49
49
|
},
|
|
50
50
|
create(context) {
|
|
@@ -164,7 +164,7 @@ export const noRuntimeNamespace = {
|
|
|
164
164
|
const FEATURE_TIERS = /\/packages\/[^/]+\/src\/(blocks|overlays|pages|layouts)\//
|
|
165
165
|
|
|
166
166
|
/** Tiers that know no feature, and therefore belong to the shared package. */
|
|
167
|
-
const VOCABULARY_TIERS = /\/apps\/[^/]+\/src\/(?:components\/)?(
|
|
167
|
+
const VOCABULARY_TIERS = /\/apps\/[^/]+\/src\/(?:components\/)?(leaves|composites|branches)\//
|
|
168
168
|
|
|
169
169
|
/**
|
|
170
170
|
* In a monorepo, the shared package stops below the block.
|
|
@@ -183,7 +183,7 @@ export const monorepoTierBelongsToItsSide = {
|
|
|
183
183
|
type: "problem",
|
|
184
184
|
docs: {
|
|
185
185
|
description:
|
|
186
|
-
"In a monorepo the shared package holds
|
|
186
|
+
"In a monorepo the shared package holds leaves, composites and branches; blocks, overlays, layouts and pages belong to the app that owns the feature.",
|
|
187
187
|
},
|
|
188
188
|
schema: [],
|
|
189
189
|
messages: {
|
|
@@ -316,7 +316,6 @@ export const sourceTierMarkerMatchesFolder = {
|
|
|
316
316
|
blocks: "block",
|
|
317
317
|
branches: "branch",
|
|
318
318
|
composites: "composite",
|
|
319
|
-
contracts: "contract",
|
|
320
319
|
layouts: "layout",
|
|
321
320
|
leaves: "leaf",
|
|
322
321
|
overlays: "overlay",
|
|
@@ -353,7 +352,7 @@ export const noShellTier = {
|
|
|
353
352
|
schema: [],
|
|
354
353
|
messages: {
|
|
355
354
|
shell:
|
|
356
|
-
"`shells/` is an untyped branch exemption. Move the owner to a named branch
|
|
355
|
+
"`shells/` is an untyped branch exemption. Move the owner to a named branch and keep vendor mechanics closed there.",
|
|
357
356
|
},
|
|
358
357
|
},
|
|
359
358
|
create(context) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The boundary between product code and `@starci/grammar`.
|
|
3
|
+
*
|
|
4
|
+
* Grammar is a business-neutral HeroUI-backed component package. Product code may compose its
|
|
5
|
+
* components with ordinary React children and typed props; this contribution deliberately does
|
|
6
|
+
* not impose a second rendering protocol or a product-specific adapter lane.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** No product-specific AST restrictions are needed for the neutral Grammar boundary. */
|
|
10
|
+
export const rules = {}
|
|
11
|
+
|
|
12
|
+
/** No repository audit is coupled to the neutral Grammar boundary. */
|
|
13
|
+
export const audits = {}
|
|
14
|
+
|
|
15
|
+
/** The neutral boundary contributes no mandatory rule levels. */
|
|
16
|
+
export const recommended = {}
|
package/index.mjs
CHANGED
|
@@ -13,8 +13,13 @@
|
|
|
13
13
|
* would leave two names for one rule and no way to tell which a message came from.
|
|
14
14
|
*/
|
|
15
15
|
import { recommended as commentsRecommended, rules as commentsRules } from "./comments.mjs"
|
|
16
|
-
import { recommended as
|
|
17
|
-
import { recommended as fileLayoutRecommended, rules as fileLayoutRules } from "./file-layout.mjs"
|
|
16
|
+
import { recommended as classNamesRecommended, rules as classNamesRules } from "./class-names.mjs"
|
|
17
|
+
import { recommended as fileLayoutRecommended, rules as fileLayoutRules } from "./file-layout.mjs"
|
|
18
|
+
import {
|
|
19
|
+
audits as grammarBoundaryAudits,
|
|
20
|
+
recommended as grammarBoundaryRecommended,
|
|
21
|
+
rules as grammarBoundaryRules,
|
|
22
|
+
} from "./grammar-boundary.mjs"
|
|
18
23
|
import { recommended as iconRecommended, rules as iconRules } from "./icon.mjs"
|
|
19
24
|
import { recommended as landmarkRecommended, rules as landmarkRules } from "./landmark.mjs"
|
|
20
25
|
import { recommended as loadingRecommended, rules as loadingRules } from "./loading.mjs"
|
|
@@ -41,8 +46,18 @@ import { recommended as vendorRecommended, rules as vendorRules } from "./vendor
|
|
|
41
46
|
/** Each law's contribution, kept separate so a duplicate name is detectable rather than silent. */
|
|
42
47
|
const CONTRIBUTIONS = [
|
|
43
48
|
{ law: "comments", rules: commentsRules, recommended: commentsRecommended },
|
|
44
|
-
{
|
|
45
|
-
|
|
49
|
+
{
|
|
50
|
+
law: "class-names",
|
|
51
|
+
rules: classNamesRules,
|
|
52
|
+
recommended: classNamesRecommended,
|
|
53
|
+
},
|
|
54
|
+
{ law: "file-layout", rules: fileLayoutRules, recommended: fileLayoutRecommended },
|
|
55
|
+
{
|
|
56
|
+
law: "grammar-boundary",
|
|
57
|
+
rules: grammarBoundaryRules,
|
|
58
|
+
recommended: grammarBoundaryRecommended,
|
|
59
|
+
audits: grammarBoundaryAudits,
|
|
60
|
+
},
|
|
46
61
|
{ law: "icon", rules: iconRules, recommended: iconRecommended },
|
|
47
62
|
{ law: "landmark", rules: landmarkRules, recommended: landmarkRecommended },
|
|
48
63
|
{ law: "loading", rules: loadingRules, recommended: loadingRecommended },
|
package/landmark.mjs
CHANGED
|
@@ -1,242 +1,29 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
/** A route layout: `layout.tsx` anywhere under an `app/` router directory. */
|
|
32
|
-
export const isRouteLayoutFile = (filename) => /\/app\/(?:.*\/)?layout\.tsx$/.test(normalize(filename))
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* A route file: the layout OR the page of a route.
|
|
36
|
-
*
|
|
37
|
-
* WHY BOTH. The landmark belongs to the outermost file that composes the routed screen, and that is
|
|
38
|
-
* not always a layout. A route with no chrome-composing layout - a scaffold, a standalone screen -
|
|
39
|
-
* has its page as the outermost composer, and forcing the mark upward would mean inventing a layout
|
|
40
|
-
* whose only job is to wrap. That was not a hypothetical: an early version of this rule allowed only
|
|
41
|
-
* layouts, and it refused three route pages that were each the sole composer of their screen and
|
|
42
|
-
* each held exactly one `main`.
|
|
43
|
-
*/
|
|
44
|
-
export const isRouteFile = (filename) => /\/app\/(?:.*\/)?(?:layout|page)\.tsx$/.test(normalize(filename))
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* A page tier file: either half of a surface under `components/pages/<Name>/`.
|
|
48
|
-
*
|
|
49
|
-
* WHY THE PAGE TIER OWNS THE MARK TOO, AND WHY THIS WAS NOT ALWAYS TRUE.
|
|
50
|
-
*
|
|
51
|
-
* The landmark used to be a named branch, so "the outermost file that composes the screen" and "a
|
|
52
|
-
* file under `app/`" were the same sentence — the branch was imported into the route file and that
|
|
53
|
-
* was that. Then the mark moved into the contract entry as `host: "main"`, and the two sentences
|
|
54
|
-
* came apart: the entry is rendered by whoever renders the screen's outermost frame, and FILE-6
|
|
55
|
-
* says that is emphatically NOT the route file. A route names which page renders at which URL and
|
|
56
|
-
* draws nothing.
|
|
57
|
-
*
|
|
58
|
-
* Held to route files only, the two laws then refused each other. Every page moved out of `app/` to
|
|
59
|
-
* satisfy FILE-6 was reported for misplacing its landmark, and the only way to satisfy both at
|
|
60
|
-
* once was to leave the page owner in the routing tree — which is the defect FILE-6 exists to
|
|
61
|
-
* prevent. A rule that can only be satisfied by breaking another rule is a finding about the rule.
|
|
62
|
-
*
|
|
63
|
-
* What the guarantee actually was, and still is: ONE main per document, owned by whoever owns the
|
|
64
|
-
* whole screen. A route file and a page component are both that. A block, composite, leaf, branch,
|
|
65
|
-
* overlay or shared layout is not, and each of those still reports — which is the case this rule was
|
|
66
|
-
* written for, because a landmark added there is a SECOND main rather than a stronger one.
|
|
67
|
-
*/
|
|
68
|
-
export const isPageTierFile = (filename) =>
|
|
69
|
-
/\/components\/pages\/[A-Z][A-Za-z0-9]*\/(?:index|component)\.tsx$/.test(normalize(filename))
|
|
70
|
-
|
|
71
|
-
/** The branches that open a landmark element. One per element, by LANDMARK-1. */
|
|
72
|
-
export const LANDMARK_BRANCHES = new Set(["Main"])
|
|
73
|
-
|
|
74
|
-
/** The branch that composes an ordinary registry node, and therefore marks a layout as chrome-owning. */
|
|
75
|
-
const TREE_BRANCH = "Tree"
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* The contract key a frame element is rendering, when it names one literally.
|
|
79
|
-
*
|
|
80
|
-
* @param node - A JSX opening element.
|
|
81
|
-
* @returns The key, or null when the element takes no literal `contract`.
|
|
82
|
-
*/
|
|
83
|
-
const contractKeyOf = (node) => {
|
|
84
|
-
const attribute = (node.attributes || []).find(
|
|
85
|
-
(candidate) => candidate.type === "JSXAttribute" && candidate.name && candidate.name.name === "contract",
|
|
86
|
-
)
|
|
87
|
-
const value = attribute && attribute.value
|
|
88
|
-
return value && value.type === "Literal" && typeof value.value === "string" ? value.value : null
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* True when this element draws a landmark.
|
|
93
|
-
*
|
|
94
|
-
* TWO SHAPES, AND THE SECOND IS THE ONE THAT LASTS. A landmark used to be a named branch, so this
|
|
95
|
-
* asked for the name. The element now belongs to the contract entry - `host: "main"` - which is
|
|
96
|
-
* why a repository can delete its `Main` branch entirely and still have exactly one main landmark:
|
|
97
|
-
* the frame reads the host from the key. Asking only for the branch name reported those repositories
|
|
98
|
-
* as having no landmark at all, which is the opposite of what they had done.
|
|
99
|
-
*
|
|
100
|
-
* @param node - A JSX opening element.
|
|
101
|
-
* @param filename - The file being linted, used to locate the contract table.
|
|
102
|
-
*/
|
|
103
|
-
const isLandmarkElement = (node, filename) =>
|
|
104
|
-
isLandmarkBranchElement(node) || isHostMainFrame(node, filename)
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* True when this element is a landmark BRANCH, named directly.
|
|
108
|
-
*
|
|
109
|
-
* @param node - A JSX opening element.
|
|
110
|
-
*/
|
|
111
|
-
const isLandmarkBranchElement = (node) =>
|
|
112
|
-
node.type === "JSXOpeningElement"
|
|
113
|
-
&& node.name
|
|
114
|
-
&& node.name.type === "JSXIdentifier"
|
|
115
|
-
&& LANDMARK_BRANCHES.has(node.name.name)
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* True when this element is an ordinary frame whose CONTRACT ENTRY declares the landmark.
|
|
119
|
-
*
|
|
120
|
-
* @param node - A JSX opening element.
|
|
121
|
-
* @param filename - The file being linted, used to locate the contract table.
|
|
122
|
-
*/
|
|
123
|
-
const isHostMainFrame = (node, filename) => {
|
|
124
|
-
if (node.type !== "JSXOpeningElement" || !node.name || node.name.type !== "JSXIdentifier") return false
|
|
125
|
-
if (LANDMARK_BRANCHES.has(node.name.name)) return false
|
|
126
|
-
const key = contractKeyOf(node)
|
|
127
|
-
return key !== null && contractHostOf(filename, key) === "main"
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* True when this file is a landmark branch's own implementation.
|
|
132
|
-
*
|
|
133
|
-
* That file draws the element by hand and must: it is the one place the element is allowed to
|
|
134
|
-
* appear, exactly as `Tree` is the one place a `div` is.
|
|
135
|
-
*
|
|
136
|
-
* @param filename - The file being linted.
|
|
137
|
-
*/
|
|
138
|
-
const isLandmarkBranchFile = (filename) =>
|
|
139
|
-
[...LANDMARK_BRANCHES].some((name) => new RegExp(`/branches/${name}/`).test(normalize(filename)))
|
|
140
|
-
|
|
141
|
-
export const rules = {
|
|
142
|
-
"routed-page-is-a-main-landmark": {
|
|
143
|
-
meta: {
|
|
144
|
-
type: "problem",
|
|
145
|
-
docs: {
|
|
146
|
-
description:
|
|
147
|
-
"A route layout that composes its own chrome must wrap the routed children in the Main branch, so the page is a landmark a reader can skip to.",
|
|
148
|
-
},
|
|
149
|
-
schema: [],
|
|
150
|
-
messages: {
|
|
151
|
-
missing:
|
|
152
|
-
"This layout composes the page chrome but never marks the routed page. Wrap `children` in the `Main` branch so assistive technology can skip the navigation to reach the page - a registry key named `*-main` is a name, not a landmark.",
|
|
153
|
-
},
|
|
154
|
-
},
|
|
155
|
-
create(context) {
|
|
156
|
-
const filename = context.filename || context.getFilename()
|
|
157
|
-
if (!isRouteLayoutFile(filename)) return {}
|
|
158
|
-
let rendersChildren = false
|
|
159
|
-
let composesChrome = false
|
|
160
|
-
let usesLandmark = false
|
|
161
|
-
let firstNode = null
|
|
162
|
-
return {
|
|
163
|
-
JSXExpressionContainer(node) {
|
|
164
|
-
if (node.expression && node.expression.type === "Identifier" && node.expression.name === "children") {
|
|
165
|
-
rendersChildren = true
|
|
166
|
-
firstNode = firstNode || node
|
|
167
|
-
}
|
|
168
|
-
},
|
|
169
|
-
Identifier(node) {
|
|
170
|
-
// `children` reaches the tree through a slot callback as often as through JSX, so the
|
|
171
|
-
// bare identifier counts too - otherwise a layout escapes by handing it to a builder.
|
|
172
|
-
if (node.name === "children" && node.parent && node.parent.type !== "Property") {
|
|
173
|
-
rendersChildren = true
|
|
174
|
-
firstNode = firstNode || node
|
|
175
|
-
}
|
|
176
|
-
},
|
|
177
|
-
JSXOpeningElement(node) {
|
|
178
|
-
if (isLandmarkElement(node, filename)) usesLandmark = true
|
|
179
|
-
if (node.name && node.name.type === "JSXIdentifier" && node.name.name === TREE_BRANCH) {
|
|
180
|
-
composesChrome = true
|
|
181
|
-
firstNode = firstNode || node
|
|
182
|
-
}
|
|
183
|
-
},
|
|
184
|
-
"Program:exit"(node) {
|
|
185
|
-
if (rendersChildren && composesChrome && !usesLandmark) {
|
|
186
|
-
context.report({ node: firstNode || node, messageId: "missing" })
|
|
187
|
-
}
|
|
188
|
-
},
|
|
189
|
-
}
|
|
190
|
-
},
|
|
191
|
-
},
|
|
192
|
-
|
|
193
|
-
"main-landmark-belongs-to-a-route-file": {
|
|
194
|
-
meta: {
|
|
195
|
-
type: "problem",
|
|
196
|
-
docs: {
|
|
197
|
-
description:
|
|
198
|
-
"The Main branch draws the document's one main landmark, so it belongs to the route file that composes the screen and nowhere below it.",
|
|
199
|
-
},
|
|
200
|
-
schema: [],
|
|
201
|
-
messages: {
|
|
202
|
-
misplaced:
|
|
203
|
-
"This draws the document's ONE main landmark, so it belongs to whoever owns the whole screen - the route's `layout.tsx` or `page.tsx`, or the page surface under `components/pages/<Name>/`. Used here it puts a second `main` in the document, which is an ambiguous landmark rather than a stronger one - a registry key named `*-main` inside a block or composite is a reading column, and its entry must not declare `host: \"main\"`.",
|
|
204
|
-
},
|
|
205
|
-
},
|
|
206
|
-
/*
|
|
207
|
-
* TWO SHAPES, TWO DIFFERENT SETS OF FILES THAT MAY HOLD THEM, and collapsing them was the bug.
|
|
208
|
-
*
|
|
209
|
-
* The `Main` BRANCH is a component somebody imports in order to wrap a screen. It belongs to the
|
|
210
|
-
* route file that composes that screen and nowhere below - a page reaching for it is the trap
|
|
211
|
-
* this law was written for, because the thing it wraps is a reading COLUMN and the document then
|
|
212
|
-
* has two mains.
|
|
213
|
-
*
|
|
214
|
-
* A frame whose CONTRACT ENTRY declares `host: "main"` is not that. Nobody imported a landmark;
|
|
215
|
-
* the registry says which element this key's node opens, and the frame obeys. That entry is
|
|
216
|
-
* rendered by whoever renders the screen's outermost node - and FILE-6 says the route file is
|
|
217
|
-
* emphatically not it, because a route mounts a page and draws nothing. Holding both shapes to
|
|
218
|
-
* route files only made the two laws refuse each other, and the only way to satisfy both was to
|
|
219
|
-
* leave the page owner in the routing tree, which is the defect FILE-6 exists to prevent.
|
|
220
|
-
*/
|
|
221
|
-
create(context) {
|
|
222
|
-
const filename = context.filename || context.getFilename()
|
|
223
|
-
if (isLandmarkBranchFile(filename)) return {}
|
|
224
|
-
const isRoute = isRouteFile(filename)
|
|
225
|
-
const isPageTier = isPageTierFile(filename)
|
|
226
|
-
return {
|
|
227
|
-
JSXOpeningElement(node) {
|
|
228
|
-
if (isLandmarkBranchElement(node)) {
|
|
229
|
-
if (!isRoute) context.report({ node, messageId: "misplaced" })
|
|
230
|
-
return
|
|
231
|
-
}
|
|
232
|
-
if (isHostMainFrame(node, filename) && !isRoute && !isPageTier) {
|
|
233
|
-
context.report({ node, messageId: "misplaced" })
|
|
234
|
-
}
|
|
235
|
-
},
|
|
236
|
-
}
|
|
237
|
-
},
|
|
238
|
-
},
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/** Every FE canon rule is an error; see `sources/fe/index.mjs` for why there is no warn tier. */
|
|
242
|
-
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
|
1
|
+
/**
|
|
2
|
+
* Accessibility landmarks for ordinary React route composition.
|
|
3
|
+
*
|
|
4
|
+
* Native HTML landmarks are intentionally independent of component implementation details. The
|
|
5
|
+
* application may pass children through layouts and pages normally.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const normalize = (filename) => String(filename || "").replace(/\\/g, "/")
|
|
9
|
+
|
|
10
|
+
/** Whether a file is a Next route layout. */
|
|
11
|
+
export const isRouteLayoutFile = (filename) => /\/app\/(?:.*\/)?layout\.tsx$/.test(normalize(filename))
|
|
12
|
+
|
|
13
|
+
/** Whether a file is a Next route entry. */
|
|
14
|
+
export const isRouteFile = (filename) => /\/app\/(?:.*\/)?(?:layout|page)\.tsx$/.test(normalize(filename))
|
|
15
|
+
|
|
16
|
+
/** Whether a file owns a page component. */
|
|
17
|
+
export const isPageTierFile = (filename) => /\/src\/components\/pages\//.test(normalize(filename))
|
|
18
|
+
|
|
19
|
+
/** Named branch owners that may provide landmark semantics. */
|
|
20
|
+
export const LANDMARK_BRANCHES = new Set(["Main"])
|
|
21
|
+
|
|
22
|
+
/** No custom landmark AST rule is needed; semantic ownership is enforced by the component code. */
|
|
23
|
+
export const rules = {}
|
|
24
|
+
|
|
25
|
+
/** No repository audit is coupled to landmark ownership. */
|
|
26
|
+
export const audits = {}
|
|
27
|
+
|
|
28
|
+
/** The landmark contribution has no mandatory rule levels. */
|
|
29
|
+
export const recommended = {}
|