@starci/eslint-canon-fe 1.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/comments.mjs +190 -0
- package/contract.mjs +1199 -0
- package/contracts.ts +312 -0
- package/file-layout.mjs +310 -0
- package/icon.mjs +309 -0
- package/index.mjs +118 -0
- package/landmark.mjs +242 -0
- package/lint-adoption.mjs +116 -0
- package/lint-escape-hatch.mjs +56 -0
- package/loading.mjs +164 -0
- package/naming.mjs +150 -0
- package/package.json +38 -0
- package/props-and-slots.mjs +188 -0
- package/props.ts +145 -0
- package/served-locale.mjs +161 -0
- package/the-split.mjs +146 -0
- package/tokens.mjs +268 -0
- package/translation.mjs +122 -0
- package/type-safety.mjs +74 -0
- package/typography.mjs +83 -0
- package/vendor-boundary.mjs +526 -0
package/icon.mjs
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold `icon.md`.
|
|
3
|
+
*
|
|
4
|
+
* Both exist because of the same escape, found by hand rather than by a gate: one leaf imported a
|
|
5
|
+
* caret straight from the glyph library, at a size off both steps and a cut outside the two the
|
|
6
|
+
* icon leaf offers. Nothing reported it. The vendor rule that guards the component library names
|
|
7
|
+
* that library only, and the token rules that police the scale read SPACING utilities - gaps,
|
|
8
|
+
* insets, offsets - so a glyph size is outside every pattern they match.
|
|
9
|
+
*
|
|
10
|
+
* The lesson is worth stating where the next rule gets written: a rule that names one vendor
|
|
11
|
+
* protects one vendor, and a rule that scans one family of utilities protects that family. Neither
|
|
12
|
+
* generalises on its own, and the gap between them is exactly where a third step gets invented.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Forward-slash form of a filename, so Windows paths compare like every other path. */
|
|
16
|
+
const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
|
|
17
|
+
|
|
18
|
+
/** The one module allowed to name a glyph from a library. */
|
|
19
|
+
const ICON_MODULE_RELATIVE = "leaves/Icon/index.tsx"
|
|
20
|
+
|
|
21
|
+
/** Repeated goal / metric cells are text-led in the legacy reference. */
|
|
22
|
+
const LABELLED_PROGRESS_ROW_RELATIVE = "composites/LabelledProgressRow/index.tsx"
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Package roots that ship glyphs.
|
|
26
|
+
*
|
|
27
|
+
* Matched as a PREFIX so a subpath cannot walk around it - the escape that started this was
|
|
28
|
+
* `@phosphor-icons/react/dist/ssr`, which an equality check on the package name does not see.
|
|
29
|
+
*/
|
|
30
|
+
const GLYPH_PACKAGES = [
|
|
31
|
+
"@phosphor-icons/",
|
|
32
|
+
"lucide-react",
|
|
33
|
+
"react-icons",
|
|
34
|
+
"@heroicons/",
|
|
35
|
+
"@tabler/icons",
|
|
36
|
+
"@fortawesome/",
|
|
37
|
+
"@mui/icons-material",
|
|
38
|
+
"@fluentui/react-icons",
|
|
39
|
+
"iconsax-react",
|
|
40
|
+
"react-feather",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
/** Package-name signal for a glyph catalogue not yet listed explicitly. */
|
|
44
|
+
const GLYPH_PACKAGE_NAME = /(?:icon|glyph|lucide|feather|tabler|fortawesome)/i
|
|
45
|
+
|
|
46
|
+
/** The two Heroicon families selected by the product roles. */
|
|
47
|
+
const HEROICON_PACKAGES = new Set([
|
|
48
|
+
"@heroicons/react/24/outline",
|
|
49
|
+
"@heroicons/react/16/solid",
|
|
50
|
+
])
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Product reactions are attributed artwork rather than glyph-library imports.
|
|
54
|
+
*
|
|
55
|
+
* Their leaf owns this closed vocabulary and maps it to checked-in Fluent Emoji Flat SVGs under
|
|
56
|
+
* `public/reactions/`; callers pass identities, never Unicode pictographs or asset paths.
|
|
57
|
+
*/
|
|
58
|
+
export const REACTION_ASSET_NAMES = new Set(["like", "love", "haha", "wow", "sad", "angry"])
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* ICON-11 - rank artwork is a second closed leaf, and it is the only one.
|
|
62
|
+
*
|
|
63
|
+
* A leaderboard place is not a feature, a state or an action, which is what the Heroicons map is
|
|
64
|
+
* for. It is an AWARD, and the product decided - explicitly, twice - that an award is drawn as the
|
|
65
|
+
* Fluent Emoji Flat medal for it. Heroicons ships no medal, so the choice was never between two
|
|
66
|
+
* glyph families; it was between the artwork the product asked for and no artwork at all.
|
|
67
|
+
*
|
|
68
|
+
* WHY THIS IS A NAMED EXEMPTION RATHER THAN A HOLE. It is bounded on three sides at once: one file
|
|
69
|
+
* may import the library, only from `@iconify/react`, and only these four artwork identities. A
|
|
70
|
+
* fifth medal, a second importing file, or a different vendor is still reported. That is the same
|
|
71
|
+
* shape as the reaction vocabulary above - a closed set owned by one leaf - and it is what keeps
|
|
72
|
+
* "the product asked for it" from becoming a sentence any call site can write.
|
|
73
|
+
*
|
|
74
|
+
* WHY IT IS NOT THE `public/` ROUTE THE REACTIONS TOOK. That route is better and it stays the
|
|
75
|
+
* default: checked-in SVG needs no network at render time and no vendor at all. It was not taken
|
|
76
|
+
* here because the four assets are not in the repository, and the exemption records a decision the
|
|
77
|
+
* product owner made knowing that - not a claim that this is the stronger mechanism.
|
|
78
|
+
*/
|
|
79
|
+
const RANK_MARK_RELATIVE = "leaves/RankMark/index.tsx"
|
|
80
|
+
|
|
81
|
+
/** The one glyph package the rank leaf may name, and nothing else may. */
|
|
82
|
+
const RANK_ARTWORK_PACKAGE = "@iconify/react"
|
|
83
|
+
|
|
84
|
+
/** The complete rank artwork vocabulary: three places, then one trophy for everyone below. */
|
|
85
|
+
export const RANK_ARTWORK_IDS = new Set([
|
|
86
|
+
"fluent-emoji-flat:1st-place-medal",
|
|
87
|
+
"fluent-emoji-flat:2nd-place-medal",
|
|
88
|
+
"fluent-emoji-flat:3rd-place-medal",
|
|
89
|
+
"fluent-emoji-flat:trophy",
|
|
90
|
+
])
|
|
91
|
+
|
|
92
|
+
/** True when this file is the rank leaf, which owns the place-to-artwork map. */
|
|
93
|
+
const isRankMarkFile = (filename) => normalizePath(filename).endsWith(`/${RANK_MARK_RELATIVE}`)
|
|
94
|
+
|
|
95
|
+
/** True for the one import the rank leaf is permitted to make. */
|
|
96
|
+
const isRankArtworkImport = (filename, source) =>
|
|
97
|
+
isRankMarkFile(filename) && source === RANK_ARTWORK_PACKAGE
|
|
98
|
+
|
|
99
|
+
/** True when this file is the icon leaf, which owns the meaning-to-glyph map. */
|
|
100
|
+
const isIconLeafFile = (filename) => normalizePath(filename).endsWith(`/${ICON_MODULE_RELATIVE}`)
|
|
101
|
+
|
|
102
|
+
/** True for the reusable repeated metric cell whose reference contains no decorative glyph. */
|
|
103
|
+
const isLabelledProgressRowFile = (filename) =>
|
|
104
|
+
normalizePath(filename).endsWith(`/${LABELLED_PROGRESS_ROW_RELATIVE}`)
|
|
105
|
+
|
|
106
|
+
/** Product source under `src/`; tooling and config are out of scope. */
|
|
107
|
+
const isSourceFile = (filename) => normalizePath(filename).includes("/src/")
|
|
108
|
+
|
|
109
|
+
/** True when an import source resolves into a glyph library, subpaths included. */
|
|
110
|
+
const isGlyphImport = (source) => {
|
|
111
|
+
const value = typeof source === "string" ? source : ""
|
|
112
|
+
const isExternalPackage = value !== "" && !value.startsWith(".") && !value.startsWith("@/")
|
|
113
|
+
return GLYPH_PACKAGES.some((pkg) => value === pkg || value.startsWith(pkg)) ||
|
|
114
|
+
(isExternalPackage && GLYPH_PACKAGE_NAME.test(value))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A glyph size written as a fraction of a step, or as an arbitrary value. */
|
|
118
|
+
const OFF_SCALE_GLYPH = /\bsize-(?:\d+\.\d+|\[[^\]]+\])/
|
|
119
|
+
|
|
120
|
+
/** True for a `className` / `class` JSX attribute. */
|
|
121
|
+
const isClassAttribute = (node) =>
|
|
122
|
+
node.type === "JSXAttribute" && node.name && (node.name.name === "className" || node.name.name === "class")
|
|
123
|
+
|
|
124
|
+
/** Static string carried by a JSX attribute, or by a module constant holding a class string. */
|
|
125
|
+
const staticText = (value) => {
|
|
126
|
+
if (!value) return null
|
|
127
|
+
if (value.type === "Literal" && typeof value.value === "string") return value.value
|
|
128
|
+
if (value.type === "TemplateLiteral" && value.expressions.length === 0) {
|
|
129
|
+
return value.quasis.map((quasi) => quasi.value.cooked).join(" ")
|
|
130
|
+
}
|
|
131
|
+
if (value.type === "JSXExpressionContainer") return staticText(value.expression)
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// -- ICON-6 ----------------------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
/** A glyph library is named in one file, and a caller asks for a MEANING instead. */
|
|
138
|
+
export const noVendorIconOutsideIconLeaf = {
|
|
139
|
+
meta: {
|
|
140
|
+
type: "problem",
|
|
141
|
+
docs: { description: "Glyph libraries are imported only by the icon leaf." },
|
|
142
|
+
schema: [],
|
|
143
|
+
messages: {
|
|
144
|
+
vendor:
|
|
145
|
+
"`{{source}}` is a glyph library, imported outside `{{leaf}}`. Reaching for it here decides three things at the call site - which library, which glyph, how big - and the next screen answers all three differently. Ask for the MEANING instead; if no meaning fits, add one to the icon leaf, which is where the answer is kept once.",
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
create(context) {
|
|
149
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
150
|
+
if (!isSourceFile(file) || isIconLeafFile(file)) return {}
|
|
151
|
+
return {
|
|
152
|
+
ImportDeclaration(node) {
|
|
153
|
+
const source = node.source && node.source.value
|
|
154
|
+
if (!isGlyphImport(source)) return
|
|
155
|
+
// ICON-11: the rank leaf owns award artwork the Heroicons map does not carry.
|
|
156
|
+
if (isRankArtworkImport(file, source)) return
|
|
157
|
+
context.report({ node, messageId: "vendor", data: { source, leaf: ICON_MODULE_RELATIVE } })
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// -- ICON-7 ----------------------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
/** The icon leaf owns the map, but that ownership does not license a second glyph vocabulary. */
|
|
166
|
+
export const heroiconsIsTheGlyphVendor = {
|
|
167
|
+
meta: {
|
|
168
|
+
type: "problem",
|
|
169
|
+
docs: { description: "Only the Heroicons heading and micro families may supply product glyphs." },
|
|
170
|
+
schema: [],
|
|
171
|
+
messages: {
|
|
172
|
+
vendor:
|
|
173
|
+
"`{{source}}` is not one of StarCi's two glyph families. Heading and leading roles use `@heroicons/react/24/outline`; chip roles use `@heroicons/react/16/solid`. Map the meaning there instead. Brand marks are exact house SVGs. Product reactions use the attributed Fluent Emoji Flat SVG subset checked into `public/reactions/`, never another glyph import.",
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
create(context) {
|
|
177
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
178
|
+
if (!isSourceFile(file)) return {}
|
|
179
|
+
return {
|
|
180
|
+
ImportDeclaration(node) {
|
|
181
|
+
const source = node.source && node.source.value
|
|
182
|
+
if (!isGlyphImport(source) || HEROICON_PACKAGES.has(String(source))) return
|
|
183
|
+
// ICON-11: Heroicons ships no medal, so the award vocabulary has its own closed leaf.
|
|
184
|
+
if (isRankArtworkImport(file, source)) return
|
|
185
|
+
context.report({ node, messageId: "vendor", data: { source } })
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// -- ICON-1 ----------------------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
/** A glyph takes one of the two steps; a fraction or an arbitrary value is a third. */
|
|
194
|
+
export const noOffScaleGlyphSize = {
|
|
195
|
+
meta: {
|
|
196
|
+
type: "problem",
|
|
197
|
+
docs: { description: "A glyph is sized by one of the two steps, never by a fraction or an arbitrary value." },
|
|
198
|
+
schema: [],
|
|
199
|
+
messages: {
|
|
200
|
+
offScale:
|
|
201
|
+
"`{{cls}}` is a glyph size off both steps. A third step is one nobody applies consistently - the author picks it for a reason true on their screen, and everyone after copies the nearest of three. Use the step the icon leaf offers, and if the case genuinely needs a size neither step gives, that is a change to the two steps rather than an exception here.",
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
create(context) {
|
|
205
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
206
|
+
if (!isSourceFile(file)) return {}
|
|
207
|
+
const scan = (node, text) => {
|
|
208
|
+
if (!text) return
|
|
209
|
+
const hit = text.match(OFF_SCALE_GLYPH)
|
|
210
|
+
if (hit) context.report({ node, messageId: "offScale", data: { cls: hit[0] } })
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
JSXAttribute(node) {
|
|
214
|
+
if (!isClassAttribute(node)) return
|
|
215
|
+
scan(node, staticText(node.value))
|
|
216
|
+
},
|
|
217
|
+
VariableDeclarator(node) {
|
|
218
|
+
scan(node, staticText(node.init))
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// -- ICON-10 ---------------------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
/** A repeated metric cell does not invent a feature glyph absent from the reference. */
|
|
227
|
+
export const noDecorativeIconInMetricCell = {
|
|
228
|
+
meta: {
|
|
229
|
+
type: "problem",
|
|
230
|
+
docs: { description: "Repeated metric cells stay text-led unless the reference explicitly contains an icon." },
|
|
231
|
+
schema: [],
|
|
232
|
+
messages: {
|
|
233
|
+
decorative:
|
|
234
|
+
"`LabelledProgressRow` is a repeated metric cell, not a feature entry. Its legacy reference is text-led, so an `Icon` here invents emphasis and repeats glyph meanings across the grid. Keep the label and figure; reserve tiny glyphs for generic state or action semantics actually present in the reference.",
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
create(context) {
|
|
238
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
239
|
+
if (!isLabelledProgressRowFile(file)) return {}
|
|
240
|
+
return {
|
|
241
|
+
JSXOpeningElement(node) {
|
|
242
|
+
if (node.name?.type === "JSXIdentifier" && node.name.name === "Icon") {
|
|
243
|
+
context.report({ node, messageId: "decorative" })
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// -- ICON-11 ---------------------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The rank leaf's exemption is a vocabulary, not a door.
|
|
254
|
+
*
|
|
255
|
+
* ICON-11 lets exactly one file import exactly one glyph package. Without this rule that file could
|
|
256
|
+
* then name ANY artwork in that package, and the exemption would have bought the product four
|
|
257
|
+
* medals and handed back an unbounded catalogue - which is the failure the icon leaf exists to
|
|
258
|
+
* prevent, reintroduced one folder over.
|
|
259
|
+
*/
|
|
260
|
+
export const rankArtworkIsAClosedSet = {
|
|
261
|
+
meta: {
|
|
262
|
+
type: "problem",
|
|
263
|
+
docs: { description: "The rank leaf names only the four approved award artworks." },
|
|
264
|
+
schema: [],
|
|
265
|
+
messages: {
|
|
266
|
+
unknown:
|
|
267
|
+
"`{{id}}` is not one of the four approved rank artworks. The vocabulary is `fluent-emoji-flat:1st-place-medal`, `2nd-place-medal`, `3rd-place-medal` and `trophy` for every place below third. A fifth artwork is a product decision about what a place MEANS, so it is made in the icon law rather than in this file.",
|
|
268
|
+
outside:
|
|
269
|
+
"`{{id}}` is rank award artwork, named outside `{{leaf}}`. The place-to-artwork map is kept in one file so a second screen cannot answer the same question differently.",
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
create(context) {
|
|
273
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
274
|
+
// A twin test proves the map is closed, which it cannot do without naming what is in it and
|
|
275
|
+
// what is not. Same exemption, same reason, as the one the double-cast rule already carries.
|
|
276
|
+
if (!isSourceFile(file) || /\.test\.tsx?$/.test(file)) return {}
|
|
277
|
+
const inRankLeaf = isRankMarkFile(file)
|
|
278
|
+
return {
|
|
279
|
+
Literal(node) {
|
|
280
|
+
if (typeof node.value !== "string") return
|
|
281
|
+
const id = node.value
|
|
282
|
+
if (!id.startsWith("fluent-emoji-flat:")) return
|
|
283
|
+
if (!inRankLeaf) {
|
|
284
|
+
context.report({ node, messageId: "outside", data: { id, leaf: RANK_MARK_RELATIVE } })
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
if (!RANK_ARTWORK_IDS.has(id)) context.report({ node, messageId: "unknown", data: { id } })
|
|
288
|
+
},
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** The rules this law contributes to the plugin. */
|
|
294
|
+
export const rules = {
|
|
295
|
+
"no-vendor-icon-outside-icon-leaf": noVendorIconOutsideIconLeaf,
|
|
296
|
+
"heroicons-is-the-glyph-vendor": heroiconsIsTheGlyphVendor,
|
|
297
|
+
"no-off-scale-glyph-size": noOffScaleGlyphSize,
|
|
298
|
+
"no-decorative-icon-in-metric-cell": noDecorativeIconInMetricCell,
|
|
299
|
+
"rank-artwork-is-a-closed-set": rankArtworkIsAClosedSet,
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The level this law asks for, as the plugin's own opinion.
|
|
304
|
+
*
|
|
305
|
+
* A consuming repository's `eslint.config.mjs` stays the authority on what is actually switched on.
|
|
306
|
+
* Both rules are exact - a package prefix and a size pattern - so neither carries the false-positive
|
|
307
|
+
* risk that would justify adopting it at `warn`.
|
|
308
|
+
*/
|
|
309
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
package/index.mjs
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The front-end plugin: every law's rules, gathered once.
|
|
3
|
+
*
|
|
4
|
+
* A consuming repository imports THIS and nothing below it. Registering rules one by one in the
|
|
5
|
+
* target's own plugin file is how the two lists drift: a law gains a rule here, nobody adds it
|
|
6
|
+
* there, and the rule ships as a document. The target's `eslint.config.mjs` stays the authority on
|
|
7
|
+
* what is switched on and at what level - that is a different question from what EXISTS, and this
|
|
8
|
+
* file answers only the second.
|
|
9
|
+
*
|
|
10
|
+
* WHAT THIS FILE REFUSES TO DO. It does not rename anything. A rule's published name is part of the
|
|
11
|
+
* law that declares it, because that name is what appears in a build log, in a disable comment and
|
|
12
|
+
* in every conversation about the failure. Aliasing one here to match a target's older spelling
|
|
13
|
+
* would leave two names for one rule and no way to tell which a message came from.
|
|
14
|
+
*/
|
|
15
|
+
import { recommended as commentsRecommended, rules as commentsRules } from "./comments.mjs"
|
|
16
|
+
import { recommended as contractRecommended, rules as contractRules } from "./contract.mjs"
|
|
17
|
+
import { recommended as fileLayoutRecommended, rules as fileLayoutRules } from "./file-layout.mjs"
|
|
18
|
+
import { recommended as iconRecommended, rules as iconRules } from "./icon.mjs"
|
|
19
|
+
import { recommended as landmarkRecommended, rules as landmarkRules } from "./landmark.mjs"
|
|
20
|
+
import { recommended as loadingRecommended, rules as loadingRules } from "./loading.mjs"
|
|
21
|
+
import {
|
|
22
|
+
audits as lintAdoptionAudits,
|
|
23
|
+
recommended as lintAdoptionRecommended,
|
|
24
|
+
rules as lintAdoptionRules,
|
|
25
|
+
} from "./lint-adoption.mjs"
|
|
26
|
+
import {
|
|
27
|
+
linterOptions as strictLinterOptions,
|
|
28
|
+
recommended as lintEscapeRecommended,
|
|
29
|
+
rules as lintEscapeRules,
|
|
30
|
+
} from "./lint-escape-hatch.mjs"
|
|
31
|
+
import { recommended as namingRecommended, rules as namingRules } from "./naming.mjs"
|
|
32
|
+
import { recommended as propsRecommended, rules as propsRules } from "./props-and-slots.mjs"
|
|
33
|
+
import { recommended as servedLocaleRecommended, rules as servedLocaleRules } from "./served-locale.mjs"
|
|
34
|
+
import { recommended as splitRecommended, rules as splitRules } from "./the-split.mjs"
|
|
35
|
+
import { recommended as tokensRecommended, rules as tokensRules } from "./tokens.mjs"
|
|
36
|
+
import { recommended as translationRecommended, rules as translationRules } from "./translation.mjs"
|
|
37
|
+
import { recommended as typeSafetyRecommended, rules as typeSafetyRules } from "./type-safety.mjs"
|
|
38
|
+
import { recommended as typographyRecommended, rules as typographyRules } from "./typography.mjs"
|
|
39
|
+
import { recommended as vendorRecommended, rules as vendorRules } from "./vendor-boundary.mjs"
|
|
40
|
+
|
|
41
|
+
/** Each law's contribution, kept separate so a duplicate name is detectable rather than silent. */
|
|
42
|
+
const CONTRIBUTIONS = [
|
|
43
|
+
{ law: "comments", rules: commentsRules, recommended: commentsRecommended },
|
|
44
|
+
{ law: "contract", rules: contractRules, recommended: contractRecommended },
|
|
45
|
+
{ law: "file-layout", rules: fileLayoutRules, recommended: fileLayoutRecommended },
|
|
46
|
+
{ law: "icon", rules: iconRules, recommended: iconRecommended },
|
|
47
|
+
{ law: "landmark", rules: landmarkRules, recommended: landmarkRecommended },
|
|
48
|
+
{ law: "loading", rules: loadingRules, recommended: loadingRecommended },
|
|
49
|
+
{
|
|
50
|
+
law: "lint-adoption",
|
|
51
|
+
rules: lintAdoptionRules,
|
|
52
|
+
recommended: lintAdoptionRecommended,
|
|
53
|
+
audits: lintAdoptionAudits,
|
|
54
|
+
},
|
|
55
|
+
{ law: "lint-escape-hatch", rules: lintEscapeRules, recommended: lintEscapeRecommended },
|
|
56
|
+
{ law: "naming", rules: namingRules, recommended: namingRecommended },
|
|
57
|
+
{ law: "props-and-slots", rules: propsRules, recommended: propsRecommended },
|
|
58
|
+
{ law: "served-locale", rules: servedLocaleRules, recommended: servedLocaleRecommended },
|
|
59
|
+
{ law: "the-split", rules: splitRules, recommended: splitRecommended },
|
|
60
|
+
{ law: "tokens", rules: tokensRules, recommended: tokensRecommended },
|
|
61
|
+
{ law: "translation", rules: translationRules, recommended: translationRecommended },
|
|
62
|
+
{ law: "type-safety", rules: typeSafetyRules, recommended: typeSafetyRecommended },
|
|
63
|
+
{ law: "typography", rules: typographyRules, recommended: typographyRecommended },
|
|
64
|
+
{ law: "vendor-boundary", rules: vendorRules, recommended: vendorRecommended },
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
/** Every gathered law, including repository audits that publish no AST rule. */
|
|
68
|
+
export const lawOwners = CONTRIBUTIONS.map((entry) => entry.law)
|
|
69
|
+
|
|
70
|
+
/** Which law declares each repository-level audit. */
|
|
71
|
+
export const auditOwners = Object.fromEntries(
|
|
72
|
+
CONTRIBUTIONS.flatMap((entry) =>
|
|
73
|
+
Object.keys(entry.audits ?? {}).map((name) => [name, entry.law]),
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
/** Repository-level audits gathered beside the plugin. */
|
|
78
|
+
export const audits = Object.fromEntries(
|
|
79
|
+
CONTRIBUTIONS.flatMap((entry) => Object.entries(entry.audits ?? {})),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Which law declares each rule.
|
|
84
|
+
*
|
|
85
|
+
* Exported because a failing rule is a question about a LAW, and the shortest path from a build log
|
|
86
|
+
* to the document that explains it is this map. It is also what the twin test walks to prove no two
|
|
87
|
+
* laws claim one name.
|
|
88
|
+
*/
|
|
89
|
+
export const ruleOwners = Object.fromEntries(
|
|
90
|
+
CONTRIBUTIONS.flatMap((entry) => Object.keys(entry.rules).map((name) => [name, entry.law])),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
/** Every rule this canon publishes, keyed by its published name. */
|
|
94
|
+
export const rules = Object.fromEntries(
|
|
95
|
+
CONTRIBUTIONS.flatMap((entry) => Object.entries(entry.rules)),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The levels this canon asks for, as the plugin's own opinion.
|
|
100
|
+
*
|
|
101
|
+
* Every FE canon rule is an error. Existing debt is fixed before adoption; lowering architecture
|
|
102
|
+
* to warn teaches every later author that the boundary is optional and is not a supported rollout.
|
|
103
|
+
*/
|
|
104
|
+
export const recommended = Object.fromEntries(
|
|
105
|
+
CONTRIBUTIONS.flatMap((entry) => Object.entries(entry.recommended)),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
/** Flat-config options that make inline directives ineffective rather than merely forbidden. */
|
|
109
|
+
export const linterOptions = strictLinterOptions
|
|
110
|
+
|
|
111
|
+
/** The plugin object, shaped the way a flat config expects it. */
|
|
112
|
+
export default {
|
|
113
|
+
meta: { name: "eslint-plugin-starci-fe" },
|
|
114
|
+
rules,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Attach this canon to a repository: one block, one branch for its layout. */
|
|
118
|
+
export { starciFeConfig, LAYOUTS } from "./lint-adoption.mjs"
|
package/landmark.mjs
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rules for the LANDMARK law - `fe/canon/patterns/landmark.md`.
|
|
3
|
+
*
|
|
4
|
+
* WHY THESE EXIST. A registry makes the failure silent. A key named `dashboard-main` records the
|
|
5
|
+
* intent exactly and renders a `div`, because the branch that draws registry nodes draws divs. An
|
|
6
|
+
* application shipped with every region correctly named and not one landmark in the document, and
|
|
7
|
+
* no gate had anything to say: a name in a key is not an element in a document.
|
|
8
|
+
*
|
|
9
|
+
* WHAT THEY HOLD, as two halves of one idea. A route layout that composes its own chrome must hand
|
|
10
|
+
* the routed children to the landmark branch; and that branch may appear only in a ROUTE file,
|
|
11
|
+
* because a second `main` is an ambiguous landmark rather than a stronger one.
|
|
12
|
+
*
|
|
13
|
+
* WHAT THEY CANNOT HOLD, stated rather than implied. A file-at-a-time rule cannot see that a layout
|
|
14
|
+
* and a page under it BOTH opened a landmark. The rules narrow the place to route files and refuse
|
|
15
|
+
* every tier below, which is where the mistake actually happens - a reading column named `*-main`
|
|
16
|
+
* drawn with the landmark branch. The remaining cross-file case is a review question, and pretending
|
|
17
|
+
* otherwise would be the rule claiming a guarantee it does not have.
|
|
18
|
+
*
|
|
19
|
+
* WHY THE `Tree` TEST IS PART OF THE FIRST RULE. Without it the rule fires on every layout that
|
|
20
|
+
* touches `children`, including the root document shell and pass-through layouts that delegate
|
|
21
|
+
* their chrome - and satisfying it there would put the second `main` in the document that the
|
|
22
|
+
* companion rule refuses. The bluntness was real, was measured, and is the reason the condition is
|
|
23
|
+
* narrower than "renders children".
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { contractHostOf } from "./contract.mjs"
|
|
27
|
+
|
|
28
|
+
/** Forward-slash form of a filename, so Windows paths compare like every other path. */
|
|
29
|
+
const normalize = (filename) => String(filename || "").replace(/\\/g, "/")
|
|
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 LAYOUT-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 LAYOUT-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 LAYOUT-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 LAYOUT-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 LAYOUT-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"]))
|