@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
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule that holds `vendor-boundary.md`.
|
|
3
|
+
*
|
|
4
|
+
* IT IS CHECKED IN BOTH DIRECTIONS, and the second direction is the whole reason this is a policy
|
|
5
|
+
* rather than a hole. Outward, a component that imports the library from the wrong folder is
|
|
6
|
+
* misfiled - that half is obvious. Inward, a file sitting in a wrapper folder that imports nothing
|
|
7
|
+
* is an ordinary component holding an exemption it does not need, and without that check the folder
|
|
8
|
+
* becomes the place difficult things go. The first thing to opt in is always something that was
|
|
9
|
+
* hard to place.
|
|
10
|
+
*
|
|
11
|
+
* THE GLYPH LIBRARY IS SOMEBODY ELSE'S RULE, deliberately. `icon.mjs` owns it, because a rule that
|
|
12
|
+
* names one vendor protects one vendor - and the gap between two such rules is where a caret came
|
|
13
|
+
* to be imported straight from a glyph package at a size that existed nowhere else, reported by
|
|
14
|
+
* nothing.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Forward-slash form of a filename, so Windows paths compare like every other path. */
|
|
18
|
+
const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
|
|
19
|
+
|
|
20
|
+
/** Dot form of a two-part JSX member name such as `Checkbox.Content`. */
|
|
21
|
+
const memberName = (node) => {
|
|
22
|
+
if (node?.type !== "JSXMemberExpression") return null
|
|
23
|
+
if (node.object?.type !== "JSXIdentifier" || node.property?.type !== "JSXIdentifier") return null
|
|
24
|
+
return `${node.object.name}.${node.property.name}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The component library this boundary owns. Its glyph counterpart belongs to `icon.mjs`. */
|
|
28
|
+
const VENDOR_PACKAGE_PREFIX = "@heroui/"
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The shell folder is closed, and it is closed to FOUR now.
|
|
32
|
+
*
|
|
33
|
+
* Three own a vendor interaction machine and ignore their interior. `RouteShell` owns a framework
|
|
34
|
+
* one: a Next segment layout is handed its page as `children`, no tier below a shell may take one,
|
|
35
|
+
* and a server component cannot convert it because a function does not serialise across that
|
|
36
|
+
* boundary. It imports no vendor at all, which is why this rule can admit it without widening what
|
|
37
|
+
* a vendor import means.
|
|
38
|
+
*/
|
|
39
|
+
const SHELL_DIR = "/src/components/shells/"
|
|
40
|
+
const FRAMEWORK_SHELL = /\/src\/components\/shells\/RouteShell\//
|
|
41
|
+
const SHELL_FILE = /\/src\/components\/shells\/(?:ModalShell|DrawerShell|DropdownShell|RouteShell)\//
|
|
42
|
+
|
|
43
|
+
/** Named surface branches may own the vendor wrapper they project a content contract into. */
|
|
44
|
+
const SURFACE_BRANCH = /\/src\/components\/branches\/(?:SurfaceCard|SurfaceAccordionCard|SurfaceListCard|SurfaceFormCard)\//
|
|
45
|
+
|
|
46
|
+
const isAllowedVendorOwner = (file) =>
|
|
47
|
+
file.includes("/src/components/leaves/") || SHELL_FILE.test(file) || SURFACE_BRANCH.test(file)
|
|
48
|
+
|
|
49
|
+
/** True for a file inside the component tree, where the boundary applies. */
|
|
50
|
+
const isComponentFile = (filename) => normalizePath(filename).includes("/src/components/")
|
|
51
|
+
|
|
52
|
+
/** True when an import names the component library, subpaths included. */
|
|
53
|
+
const isVendorImport = (source) => typeof source === "string" && source.startsWith(VENDOR_PACKAGE_PREFIX)
|
|
54
|
+
|
|
55
|
+
/** Read a two-part JSX member such as `Modal.Body`. */
|
|
56
|
+
const jsxMemberName = (node) => {
|
|
57
|
+
if (node?.type !== "JSXMemberExpression") return null
|
|
58
|
+
if (node.object?.type !== "JSXIdentifier" || node.property?.type !== "JSXIdentifier") return null
|
|
59
|
+
return `${node.object.name}.${node.property.name}`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// -- VENDOR-1 · VENDOR-2 ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
/** The library belongs to two folders, and a wrapper folder that wraps nothing is misfiled. */
|
|
65
|
+
export const vendorBoundary = {
|
|
66
|
+
meta: {
|
|
67
|
+
type: "problem",
|
|
68
|
+
docs: { description: "Vendor imports belong to leaves, the two covering shells, and named surface branches." },
|
|
69
|
+
schema: [],
|
|
70
|
+
messages: {
|
|
71
|
+
outside:
|
|
72
|
+
"This component does not own a vendor primitive. Vendor imports belong to leaves, ModalShell/DrawerShell, and the named SurfaceCard family branches that project contracts into vendor bodies.",
|
|
73
|
+
emptyShell:
|
|
74
|
+
"ModalShell/DrawerShell must wrap their vendor covering primitive; otherwise this is an ordinary branch in the wrong tier.",
|
|
75
|
+
unknownShell:
|
|
76
|
+
"Only ModalShell, DrawerShell, DropdownShell and RouteShell are shells. This component must use contract + render as a branch; needing an arbitrary vendor children slot does not create another one - and the list disagreeing with canon is a gate bug, which is how this message came to name two when the tree said three.",
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
create(context) {
|
|
80
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
81
|
+
// Outside the component tree, a provider standing the library up for the whole application is a
|
|
82
|
+
// different thing from a component pulling in a widget.
|
|
83
|
+
if (!isComponentFile(file)) return {}
|
|
84
|
+
const isShellDirectory = file.includes(SHELL_DIR)
|
|
85
|
+
const isShell = SHELL_FILE.test(file)
|
|
86
|
+
const isTestFile = /\.(?:test|spec)\.[jt]sx?$/.test(file)
|
|
87
|
+
const isAllowed = isAllowedVendorOwner(file)
|
|
88
|
+
let importsVendor = false
|
|
89
|
+
return {
|
|
90
|
+
ImportDeclaration(node) {
|
|
91
|
+
if (!isVendorImport(node.source && node.source.value)) return
|
|
92
|
+
importsVendor = true
|
|
93
|
+
if (isAllowed) return
|
|
94
|
+
context.report({ node, messageId: "outside" })
|
|
95
|
+
},
|
|
96
|
+
"Program:exit"(node) {
|
|
97
|
+
if (isTestFile) return
|
|
98
|
+
if (isShellDirectory && !isShell) context.report({ node, messageId: "unknownShell" })
|
|
99
|
+
/*
|
|
100
|
+
* A shell with no vendor import is empty - except the one whose mechanic is the FRAMEWORK's.
|
|
101
|
+
* `RouteShell` converts the children a Next layout is handed into the component every tier
|
|
102
|
+
* below expects, and it imports nothing to do it. Demanding a vendor primitive there would
|
|
103
|
+
* force an import that means nothing, which is how a rule teaches somebody to add noise.
|
|
104
|
+
*/
|
|
105
|
+
else if (isShell && !importsVendor && !FRAMEWORK_SHELL.test(file)) context.report({ node, messageId: "emptyShell" })
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// -- VENDOR-6 --------------------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/** A content-agnostic modal shell owns one zero-inset vendor body as the scroll region. */
|
|
114
|
+
export const modalShellOwnsScrollBody = {
|
|
115
|
+
meta: {
|
|
116
|
+
type: "problem",
|
|
117
|
+
docs: { description: "ModalShell keeps one zero-inset Modal.Body as its scroll region." },
|
|
118
|
+
schema: [],
|
|
119
|
+
messages: {
|
|
120
|
+
missing:
|
|
121
|
+
"ModalShell must keep one `Modal.Body` around its uninterpreted children: that vendor body is the dialog's scroll region, not a second content surface.",
|
|
122
|
+
inset:
|
|
123
|
+
"ModalShell's body must be `className=\"p-0\"`. The mounted contract owns layout; vendor body padding plus contract padding creates a doubled inset.",
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
create(context) {
|
|
127
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
128
|
+
if (!/\/src\/components\/shells\/ModalShell\/index\.tsx$/.test(file)) return {}
|
|
129
|
+
let hasBody = false
|
|
130
|
+
return {
|
|
131
|
+
JSXOpeningElement(node) {
|
|
132
|
+
if (jsxMemberName(node.name) !== "Modal.Body") return
|
|
133
|
+
hasBody = true
|
|
134
|
+
const className = (node.attributes || []).find((attribute) =>
|
|
135
|
+
attribute.type === "JSXAttribute"
|
|
136
|
+
&& attribute.name?.type === "JSXIdentifier"
|
|
137
|
+
&& attribute.name.name === "className")
|
|
138
|
+
if (className?.value?.type === "Literal" && className.value.value === "p-0") return
|
|
139
|
+
context.report({ node, messageId: "inset" })
|
|
140
|
+
},
|
|
141
|
+
"Program:exit"(node) {
|
|
142
|
+
if (!hasBody) context.report({ node, messageId: "missing" })
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// -- VENDOR-7 --------------------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
/** The house Field fixes the vendor input variant used on bounded form surfaces. */
|
|
151
|
+
export const fieldInputUsesSecondaryVariant = {
|
|
152
|
+
meta: {
|
|
153
|
+
type: "problem",
|
|
154
|
+
docs: { description: "The Field leaf uses HeroUI Input's secondary surface variant." },
|
|
155
|
+
schema: [],
|
|
156
|
+
messages: {
|
|
157
|
+
variant:
|
|
158
|
+
"The house Field sits inside a bounded form surface. Its HeroUI Input must use `variant=\"secondary\"`; the default variant draws a competing field surface.",
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
create(context) {
|
|
162
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
163
|
+
if (!/\/src\/components\/leaves\/Field\/index\.tsx$/.test(file)) return {}
|
|
164
|
+
const inputBindings = new Set()
|
|
165
|
+
return {
|
|
166
|
+
ImportDeclaration(node) {
|
|
167
|
+
if (node.source?.value !== "@heroui/react") return
|
|
168
|
+
for (const specifier of node.specifiers || []) {
|
|
169
|
+
if (specifier.imported?.name === "Input" && specifier.local?.name) inputBindings.add(specifier.local.name)
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
JSXOpeningElement(node) {
|
|
173
|
+
const name = node.name?.type === "JSXIdentifier" ? node.name.name : null
|
|
174
|
+
if (!name || !inputBindings.has(name)) return
|
|
175
|
+
const variant = (node.attributes || []).find((attribute) =>
|
|
176
|
+
attribute.type === "JSXAttribute"
|
|
177
|
+
&& attribute.name?.type === "JSXIdentifier"
|
|
178
|
+
&& attribute.name.name === "variant")
|
|
179
|
+
if (variant?.value?.type === "Literal" && variant.value.value === "secondary") return
|
|
180
|
+
context.report({ node, messageId: "variant" })
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// -- VENDOR-9 --------------------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
/** Field labels stay textual instead of inferring decorative glyphs from the input kind. */
|
|
189
|
+
export const fieldLabelIsTextOnly = {
|
|
190
|
+
meta: {
|
|
191
|
+
type: "problem",
|
|
192
|
+
docs: { description: "The house Field label contains no inferred kind icon." },
|
|
193
|
+
schema: [],
|
|
194
|
+
messages: {
|
|
195
|
+
icon:
|
|
196
|
+
"Field labels are text-only in the source design. Do not infer a decorative icon from the input kind; an icon belongs only to a control with its own action, such as password visibility.",
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
create(context) {
|
|
200
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
201
|
+
if (!/\/src\/components\/leaves\/Field\/index\.tsx$/.test(file)) return {}
|
|
202
|
+
const iconBindings = new Set()
|
|
203
|
+
return {
|
|
204
|
+
ImportDeclaration(node) {
|
|
205
|
+
const source = normalizePath(node.source?.value)
|
|
206
|
+
if (!/\/components\/leaves\/Icon$/.test(source)) return
|
|
207
|
+
for (const specifier of node.specifiers || []) {
|
|
208
|
+
if (specifier.imported?.name === "Icon" && specifier.local?.name) iconBindings.add(specifier.local.name)
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
JSXOpeningElement(node) {
|
|
212
|
+
const name = node.name?.type === "JSXIdentifier" ? node.name.name : null
|
|
213
|
+
if (!name || !iconBindings.has(name)) return
|
|
214
|
+
let ancestor = node.parent
|
|
215
|
+
while (ancestor) {
|
|
216
|
+
if (
|
|
217
|
+
ancestor.type === "JSXElement"
|
|
218
|
+
&& ancestor.openingElement?.name?.type === "JSXIdentifier"
|
|
219
|
+
&& ancestor.openingElement.name.name === "label"
|
|
220
|
+
) {
|
|
221
|
+
context.report({ node, messageId: "icon" })
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
ancestor = ancestor.parent
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// -- VENDOR-8 --------------------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
/** An overlay is already bounded and cannot directly mount a second named surface branch. */
|
|
234
|
+
export const noSurfaceBranchInOverlay = {
|
|
235
|
+
meta: {
|
|
236
|
+
type: "problem",
|
|
237
|
+
docs: { description: "An overlay does not directly mount a named surface branch." },
|
|
238
|
+
schema: [],
|
|
239
|
+
messages: {
|
|
240
|
+
nested:
|
|
241
|
+
"This overlay is already the bounded surface. Do not mount `{{surface}}` inside it; use headings, spacing, rows and controls directly.",
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
create(context) {
|
|
245
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
246
|
+
if (!file.includes("/src/components/overlays/")) return {}
|
|
247
|
+
return {
|
|
248
|
+
ImportDeclaration(node) {
|
|
249
|
+
const source = normalizePath(node.source?.value)
|
|
250
|
+
const match = source.match(/\/components\/branches\/(SurfaceCard|SurfaceAccordionCard|SurfaceListCard|SurfaceFormCard)$/)
|
|
251
|
+
if (match) context.report({ node, messageId: "nested", data: { surface: match[1] } })
|
|
252
|
+
},
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// -- VENDOR-10 -------------------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
/** TextLink delegates link interaction and hover treatment to HeroUI Link. */
|
|
260
|
+
export const textLinkUsesHeroLink = {
|
|
261
|
+
meta: {
|
|
262
|
+
type: "problem",
|
|
263
|
+
docs: { description: "TextLink wraps HeroUI Link instead of hand-drawing link behavior." },
|
|
264
|
+
schema: [],
|
|
265
|
+
messages: {
|
|
266
|
+
missing: "TextLink must import HeroUI `Link`. The vendor owns link hover, focus and interaction states.",
|
|
267
|
+
handmade: "Do not rebuild a HeroUI Link with a raw button or local className. Wrap HeroUI `Link` and pass `onPress`.",
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
create(context) {
|
|
271
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
272
|
+
if (!/\/src\/components\/leaves\/TextLink\/index\.tsx$/.test(file)) return {}
|
|
273
|
+
let hasHeroLink = false
|
|
274
|
+
return {
|
|
275
|
+
ImportDeclaration(node) {
|
|
276
|
+
if (node.source?.value !== "@heroui/react") return
|
|
277
|
+
hasHeroLink ||= (node.specifiers || []).some((specifier) => specifier.imported?.name === "Link")
|
|
278
|
+
},
|
|
279
|
+
JSXOpeningElement(node) {
|
|
280
|
+
if (node.name?.type === "JSXIdentifier" && node.name.name === "button") context.report({ node, messageId: "handmade" })
|
|
281
|
+
const className = (node.attributes || []).find(
|
|
282
|
+
(attribute) => attribute.type === "JSXAttribute" && attribute.name?.name === "className",
|
|
283
|
+
)
|
|
284
|
+
const classText = className?.value?.type === "Literal" ? String(className.value.value || "") : ""
|
|
285
|
+
if (/(?:hover:|underline)/.test(classText)) {
|
|
286
|
+
context.report({ node: className, messageId: "handmade" })
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
"Program:exit"(node) {
|
|
290
|
+
if (!hasHeroLink) context.report({ node, messageId: "missing" })
|
|
291
|
+
},
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// -- VENDOR-11 -------------------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
/** The account block owns the product choices; DropdownShell alone owns HeroUI mechanics. */
|
|
299
|
+
export const accountControlOwnsDropdown = {
|
|
300
|
+
meta: {
|
|
301
|
+
type: "problem",
|
|
302
|
+
docs: { description: "AccountMenu is a block over DropdownShell; only the shell imports HeroUI Dropdown." },
|
|
303
|
+
schema: [],
|
|
304
|
+
messages: {
|
|
305
|
+
dropdown: "DropdownShell must import HeroUI `Dropdown`; vendor mechanics belong to the shell.",
|
|
306
|
+
shell: "AccountMenu's pure block half must compose DropdownShell instead of importing HeroUI directly.",
|
|
307
|
+
vendor: "AccountMenu is a product block. Import DropdownShell; do not own HeroUI Dropdown here.",
|
|
308
|
+
menu: "ShellNav must render the AccountMenu block from `components/blocks/auth/AccountMenu`.",
|
|
309
|
+
direct: "The account IconButton may not carry an action in ShellNav. Guests open AccountMenu; its Sign in or Sign up choice opens the modal.",
|
|
310
|
+
pieces: "AccountMenu passes typed section data to DropdownShell; it must not import shell Section or Item pieces and rebuild vendor anatomy itself.",
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
create(context) {
|
|
314
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
315
|
+
const isDropdown = /\/src\/components\/shells\/DropdownShell\/index\.tsx$/.test(file)
|
|
316
|
+
const isMenu = /\/src\/components\/blocks\/auth\/AccountMenu\/component\.tsx$/.test(file)
|
|
317
|
+
const isNav = /\/src\/components\/layouts\/ShellNav\/component\.tsx$/.test(file)
|
|
318
|
+
if (!isDropdown && !isMenu && !isNav) return {}
|
|
319
|
+
let hasOwner = false
|
|
320
|
+
return {
|
|
321
|
+
ExportNamedDeclaration(node) {
|
|
322
|
+
if (!isDropdown) return
|
|
323
|
+
const source = context.sourceCode || context.getSourceCode()
|
|
324
|
+
if (/\bDropdownShell(?:Item|Section)\b/.test(source.getText(node))) {
|
|
325
|
+
context.report({ node, messageId: "pieces" })
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
ImportDeclaration(node) {
|
|
329
|
+
if (isDropdown && node.source?.value === "@heroui/react") {
|
|
330
|
+
hasOwner ||= (node.specifiers || []).some((specifier) => specifier.imported?.name === "Dropdown")
|
|
331
|
+
}
|
|
332
|
+
if (isMenu && node.source?.value === "@heroui/react") context.report({ node, messageId: "vendor" })
|
|
333
|
+
if (isMenu && /\/components\/shells\/DropdownShell$/.test(normalizePath(node.source?.value))) {
|
|
334
|
+
hasOwner = true
|
|
335
|
+
if ((node.specifiers || []).some((specifier) =>
|
|
336
|
+
/^(?:DropdownShellSection|DropdownShellItem)/.test(specifier.imported?.name || ""))) {
|
|
337
|
+
context.report({ node, messageId: "pieces" })
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (isNav && /\/components\/blocks\/auth\/AccountMenu$/.test(normalizePath(node.source?.value))) hasOwner = true
|
|
341
|
+
},
|
|
342
|
+
JSXOpeningElement(node) {
|
|
343
|
+
if (!isNav || node.name?.type !== "JSXIdentifier" || node.name.name !== "IconButton") return
|
|
344
|
+
const props = (node.attributes || []).find((attribute) => attribute.type === "JSXAttribute" && attribute.name?.name === "props")
|
|
345
|
+
const expression = props?.value?.type === "JSXExpressionContainer" ? props.value.expression : null
|
|
346
|
+
const isAccount = expression?.type === "ObjectExpression" && expression.properties.some((property) =>
|
|
347
|
+
property.type === "Property" && property.key?.name === "icon" && property.value?.value === "account")
|
|
348
|
+
const hasAction = (node.attributes || []).some((attribute) => attribute.type === "JSXAttribute" && attribute.name?.name === "on")
|
|
349
|
+
if (isAccount && hasAction) context.report({ node, messageId: "direct" })
|
|
350
|
+
},
|
|
351
|
+
"Program:exit"(node) {
|
|
352
|
+
if (hasOwner) return
|
|
353
|
+
context.report({ node, messageId: isDropdown ? "dropdown" : isMenu ? "shell" : "menu" })
|
|
354
|
+
},
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// -- VENDOR-13 -------------------------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
/** Checkbox.Content owns the control, indicator and label so every visible word toggles it. */
|
|
362
|
+
export const checkboxKeepsCompoundAnatomy = {
|
|
363
|
+
meta: {
|
|
364
|
+
type: "problem",
|
|
365
|
+
docs: { description: "Keep the required HeroUI Checkbox nesting inside the house leaf." },
|
|
366
|
+
schema: [],
|
|
367
|
+
messages: {
|
|
368
|
+
anatomy:
|
|
369
|
+
"Checkbox must nest `HeroCheckbox.Control` (and its Indicator) inside `HeroCheckbox.Content`. Sibling Control and Content leave the visible label outside the checkbox's press target.",
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
create(context) {
|
|
373
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
374
|
+
if (!/\/src\/components\/leaves\/Checkbox\/index\.tsx$/.test(file)) return {}
|
|
375
|
+
let hasContent = false
|
|
376
|
+
let controlInsideContent = false
|
|
377
|
+
let indicatorInsideControl = false
|
|
378
|
+
const hasAncestor = (node, expected) => {
|
|
379
|
+
let ancestor = node.parent
|
|
380
|
+
while (ancestor) {
|
|
381
|
+
if (ancestor.type === "JSXElement" && memberName(ancestor.openingElement?.name) === expected) return true
|
|
382
|
+
ancestor = ancestor.parent
|
|
383
|
+
}
|
|
384
|
+
return false
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
JSXOpeningElement(node) {
|
|
388
|
+
const name = memberName(node.name)
|
|
389
|
+
if (name === "HeroCheckbox.Content") hasContent = true
|
|
390
|
+
if (name === "HeroCheckbox.Control" && hasAncestor(node, "HeroCheckbox.Content")) controlInsideContent = true
|
|
391
|
+
if (name === "HeroCheckbox.Indicator" && hasAncestor(node, "HeroCheckbox.Control")) indicatorInsideControl = true
|
|
392
|
+
},
|
|
393
|
+
"Program:exit"(node) {
|
|
394
|
+
if (!hasContent || !controlInsideContent || !indicatorInsideControl) {
|
|
395
|
+
context.report({ node, messageId: "anatomy" })
|
|
396
|
+
}
|
|
397
|
+
},
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// -- VENDOR-14 -------------------------------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
/** Internal StarCi navigation is an action resolved by connected code, never an href. */
|
|
405
|
+
export const noInternalStarciHref = {
|
|
406
|
+
meta: {
|
|
407
|
+
type: "problem",
|
|
408
|
+
docs: { description: "Route internal StarCi destinations through connected router.push actions." },
|
|
409
|
+
schema: [],
|
|
410
|
+
messages: {
|
|
411
|
+
internal:
|
|
412
|
+
"Internal StarCi navigation must not use `href`. Keep the destination in connected code, report an id/press from the pure component, and call `router.push` there.",
|
|
413
|
+
leaf:
|
|
414
|
+
"This internal-navigation leaf must not declare or render `href`; it reports `on.press` and leaves routing to its connected owner.",
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
create(context) {
|
|
418
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
419
|
+
if (!file.includes("/src/") || /\.(?:test|spec)\.[jt]sx?$/.test(file)) return {}
|
|
420
|
+
const internalOnlyLeaf = /\/src\/components\/leaves\/(?:Checkbox|NavLink|QuickActionRow|SeeMoreLink)\/index\.tsx$/.test(file)
|
|
421
|
+
const literalText = (node) => {
|
|
422
|
+
if (node?.type === "Literal" && typeof node.value === "string") return node.value
|
|
423
|
+
if (node?.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0]?.value?.cooked || ""
|
|
424
|
+
return null
|
|
425
|
+
}
|
|
426
|
+
const isInternal = (value) => value.startsWith("/") || /^https?:\/\/(?:www\.)?academy\.starci\.org(?:\/|$)/i.test(value)
|
|
427
|
+
return {
|
|
428
|
+
JSXAttribute(node) {
|
|
429
|
+
if (node.name?.name !== "href") return
|
|
430
|
+
if (internalOnlyLeaf) {
|
|
431
|
+
context.report({ node, messageId: "leaf" })
|
|
432
|
+
return
|
|
433
|
+
}
|
|
434
|
+
const value = node.value?.type === "JSXExpressionContainer" ? literalText(node.value.expression) : literalText(node.value)
|
|
435
|
+
if (value !== null && isInternal(value)) context.report({ node, messageId: "internal" })
|
|
436
|
+
},
|
|
437
|
+
Property(node) {
|
|
438
|
+
const key = node.key?.type === "Literal" ? node.key.value : node.key?.name
|
|
439
|
+
const value = literalText(node.value)
|
|
440
|
+
if ((key === "href" || key === "externalHref") && value !== null && isInternal(value)) {
|
|
441
|
+
context.report({ node, messageId: "internal" })
|
|
442
|
+
}
|
|
443
|
+
},
|
|
444
|
+
TSPropertySignature(node) {
|
|
445
|
+
if (!internalOnlyLeaf || node.key?.name !== "href") return
|
|
446
|
+
context.report({ node, messageId: "leaf" })
|
|
447
|
+
},
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// -- VENDOR-12 -------------------------------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
/** The auth overlay projects one already-owned column and adds no vertical inset of its own. */
|
|
455
|
+
export const authOverlayOwnsSingleContentHost = {
|
|
456
|
+
meta: {
|
|
457
|
+
type: "problem",
|
|
458
|
+
docs: { description: "Authentication has one zero-inset content host inside ModalShell." },
|
|
459
|
+
schema: [],
|
|
460
|
+
messages: {
|
|
461
|
+
duplicate: "SignInOverlay must project with `ContractContent`, not open another `Tree` around a panel that already owns the same host.",
|
|
462
|
+
missing: "SignInOverlay must import `ContractContent` for its already-hosted projection.",
|
|
463
|
+
inset: "`centred-page-column` must not add py/pt/pb. ModalShell is zero-inset and the auth content touches that scroll region without a second vertical padding band.",
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
create(context) {
|
|
467
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
468
|
+
const isOverlay = /\/src\/components\/overlays\/auth\/SignInOverlay\/component\.tsx$/.test(file)
|
|
469
|
+
const isContracts = /\/src\/components\/contracts\/index\.ts$/.test(file)
|
|
470
|
+
if (!isOverlay && !isContracts) return {}
|
|
471
|
+
let hasContent = false
|
|
472
|
+
return {
|
|
473
|
+
ImportDeclaration(node) {
|
|
474
|
+
if (!isOverlay) return
|
|
475
|
+
/*
|
|
476
|
+
* Matched by the NAME imported, not by where it came from.
|
|
477
|
+
*
|
|
478
|
+
* The path test this replaced only recognised `components/branches/Tree`,
|
|
479
|
+
* which is the single-app layout. A workspace that publishes the same
|
|
480
|
+
* branches from a package satisfied the law and failed the rule, with
|
|
481
|
+
* the only available remedy being an import of a module that does not
|
|
482
|
+
* exist there. `ContractContent` and `Tree` are canon vocabulary: a file
|
|
483
|
+
* importing either means the same thing wherever it resolves from.
|
|
484
|
+
*/
|
|
485
|
+
for (const specifier of node.specifiers || []) {
|
|
486
|
+
if (specifier.imported?.name === "ContractContent") hasContent = true
|
|
487
|
+
if (specifier.imported?.name === "Tree") context.report({ node: specifier, messageId: "duplicate" })
|
|
488
|
+
}
|
|
489
|
+
},
|
|
490
|
+
Property(node) {
|
|
491
|
+
if (!isContracts) return
|
|
492
|
+
const key = node.key?.type === "Literal" ? node.key.value : node.key?.name
|
|
493
|
+
if (key !== "centred-page-column") return
|
|
494
|
+
const source = context.sourceCode || context.getSourceCode()
|
|
495
|
+
if (/["'`](?:py|pt|pb)-/.test(source.getText(node))) context.report({ node, messageId: "inset" })
|
|
496
|
+
},
|
|
497
|
+
"Program:exit"(node) {
|
|
498
|
+
if (isOverlay && !hasContent) context.report({ node, messageId: "missing" })
|
|
499
|
+
},
|
|
500
|
+
}
|
|
501
|
+
},
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** The rules this law contributes to the plugin. */
|
|
505
|
+
export const rules = {
|
|
506
|
+
"vendor-boundary": vendorBoundary,
|
|
507
|
+
"modal-shell-owns-scroll-body": modalShellOwnsScrollBody,
|
|
508
|
+
"field-input-uses-secondary-variant": fieldInputUsesSecondaryVariant,
|
|
509
|
+
"field-label-is-text-only": fieldLabelIsTextOnly,
|
|
510
|
+
"no-surface-branch-in-overlay": noSurfaceBranchInOverlay,
|
|
511
|
+
"text-link-uses-hero-link": textLinkUsesHeroLink,
|
|
512
|
+
"account-control-owns-dropdown": accountControlOwnsDropdown,
|
|
513
|
+
"auth-overlay-owns-single-content-host": authOverlayOwnsSingleContentHost,
|
|
514
|
+
"checkbox-keeps-compound-anatomy": checkboxKeepsCompoundAnatomy,
|
|
515
|
+
"no-internal-starci-href": noInternalStarciHref,
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* The level this law asks for, as the plugin's own opinion.
|
|
520
|
+
*
|
|
521
|
+
* A repository adopting this with history should expect the outward half to report and the inward
|
|
522
|
+
* half to be silent, because `shells/` will not exist yet. Creating it is the migration: every
|
|
523
|
+
* vendor container currently wrapped as something else moves there, and each move is what makes the
|
|
524
|
+
* inward check meaningful afterwards.
|
|
525
|
+
*/
|
|
526
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|