@starci/eslint-canon-fe 1.3.2 → 1.4.1
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/contract.mjs +1295 -1363
- package/contracts.ts +1 -1
- package/file-layout.mjs +310 -414
- package/icon.mjs +8 -7
- package/landmark.mjs +242 -242
- package/lint-adoption.mjs +116 -116
- package/package.json +38 -38
- package/props-and-slots.mjs +14 -278
- package/props.ts +33 -33
- package/served-locale.mjs +1 -1
- package/the-split.mjs +2 -2
- package/typography.mjs +83 -83
- package/vendor-boundary.mjs +62 -85
package/props.ts
CHANGED
|
@@ -61,13 +61,13 @@ export type LeafProps<D extends ComponentData, A extends ComponentActions = Comp
|
|
|
61
61
|
/**
|
|
62
62
|
* A COMPOSITE's props. The same three slots a leaf has, and the absence of a fourth is the point.
|
|
63
63
|
*
|
|
64
|
-
* A composite is a CLOSED shape: it assembles leaves into an arrangement a caller can use but
|
|
65
|
-
* cannot rearrange. That boundary with `branch` is held by this type rather than by a convention -
|
|
66
|
-
* there is no `render` here, so an author who wanted a slot cannot express one and has to decide
|
|
67
|
-
* which tier they are actually writing. A boundary the compiler holds does not drift.
|
|
68
|
-
* CLOSED DOES NOT MEAN FREEHAND. A composite still assembles through a typed `Tree` contract;
|
|
69
|
-
* raw structural markup would create an unnamed layout outside the registry. If a caller may
|
|
70
|
-
* supply the content or recipe, the component is a branch rather than a composite.
|
|
64
|
+
* A composite is a CLOSED shape: it assembles leaves into an arrangement a caller can use but
|
|
65
|
+
* cannot rearrange. That boundary with `branch` is held by this type rather than by a convention -
|
|
66
|
+
* there is no `render` here, so an author who wanted a slot cannot express one and has to decide
|
|
67
|
+
* which tier they are actually writing. A boundary the compiler holds does not drift.
|
|
68
|
+
* CLOSED DOES NOT MEAN FREEHAND. A composite still assembles through a typed `Tree` contract;
|
|
69
|
+
* raw structural markup would create an unnamed layout outside the registry. If a caller may
|
|
70
|
+
* supply the content or recipe, the component is a branch rather than a composite.
|
|
71
71
|
*
|
|
72
72
|
* It is structurally identical to {@link LeafProps} and deliberately NOT an alias of it: the two
|
|
73
73
|
* tiers answer different questions, and a shared name would let a reader conclude they are the same
|
|
@@ -85,18 +85,18 @@ export type CompositeProps<D extends ComponentData, A extends ComponentActions =
|
|
|
85
85
|
*
|
|
86
86
|
* THIS IS THE SLOT THAT USED TO BE `children`, AND THE CHANGE IS NOT COSMETIC. `children` is markup
|
|
87
87
|
* that has already been built: it can be a `.map`, a ternary, a fragment, or a subtree nobody named,
|
|
88
|
-
* so nothing above it can say what is inside a node and no rule can check. `render` is a contract
|
|
89
|
-
* value in one of two lanes. This alias is the bound lane: Tree and ordinary branches receive
|
|
90
|
-
* named slots or an explicit projection. A data-driven surface uses {@link ContractRenderBranchProps}
|
|
91
|
-
* to receive a stable component type and pass changing runtime `props` into it.
|
|
88
|
+
* so nothing above it can say what is inside a node and no rule can check. `render` is a contract
|
|
89
|
+
* value in one of two lanes. This alias is the bound lane: Tree and ordinary branches receive
|
|
90
|
+
* named slots or an explicit projection. A data-driven surface uses {@link ContractRenderBranchProps}
|
|
91
|
+
* to receive a stable component type and pass changing runtime `props` into it.
|
|
92
92
|
*
|
|
93
93
|
* `contract` and `render` are one decision written twice, and `ContractComponent<K>` ties them together:
|
|
94
94
|
* the key fixes which slots exist and what may fill each, so a card cannot say one thing and hold
|
|
95
95
|
* another. A wrong leaf, wrong leaf props, a node where a leaf was asked for, a missing slot and a
|
|
96
96
|
* slot nobody declared are five compile errors rather than five things a reviewer has to spot.
|
|
97
97
|
*
|
|
98
|
-
* A bare callback or JSX value may not arrive: neither carries the exact key metadata that makes
|
|
99
|
-
* `contract` and `render` one checked decision.
|
|
98
|
+
* A bare callback or JSX value may not arrive: neither carries the exact key metadata that makes
|
|
99
|
+
* `contract` and `render` one checked decision.
|
|
100
100
|
*
|
|
101
101
|
* THERE IS NO LIST VARIANT, and there does not need to be. A slot that holds many says so in the
|
|
102
102
|
* table with `repeats: true`; that slot becomes an array. `restingCount` separately fixes how many
|
|
@@ -110,26 +110,26 @@ export type BranchProps<
|
|
|
110
110
|
readonly props: D
|
|
111
111
|
readonly on?: A
|
|
112
112
|
readonly contract: K
|
|
113
|
-
readonly render: ContractComponent<NoInfer<K>>
|
|
114
|
-
readonly isLoading?: boolean
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* A data-driven contract host's props. Runtime data stays in `props`; `render` is a stable real
|
|
119
|
-
* component type branded for the exact key. `SurfaceListCard` uses this lane because a repeated
|
|
120
|
-
* domain collection must not be closed into a new callback descriptor at every host call site.
|
|
121
|
-
*/
|
|
122
|
-
export type ContractRenderBranchProps<
|
|
123
|
-
D extends ComponentData,
|
|
124
|
-
K extends ContractKey,
|
|
125
|
-
A extends ComponentActions = ComponentActions,
|
|
126
|
-
> = {
|
|
127
|
-
readonly props: D
|
|
128
|
-
readonly on?: A
|
|
129
|
-
readonly contract: K
|
|
130
|
-
readonly render: ContractComponent<NoInfer<K>, LeafProps<D, A>>
|
|
131
|
-
readonly isLoading?: boolean
|
|
132
|
-
}
|
|
113
|
+
readonly render: ContractComponent<NoInfer<K>>
|
|
114
|
+
readonly isLoading?: boolean
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A data-driven contract host's props. Runtime data stays in `props`; `render` is a stable real
|
|
119
|
+
* component type branded for the exact key. `SurfaceListCard` uses this lane because a repeated
|
|
120
|
+
* domain collection must not be closed into a new callback descriptor at every host call site.
|
|
121
|
+
*/
|
|
122
|
+
export type ContractRenderBranchProps<
|
|
123
|
+
D extends ComponentData,
|
|
124
|
+
K extends ContractKey,
|
|
125
|
+
A extends ComponentActions = ComponentActions,
|
|
126
|
+
> = {
|
|
127
|
+
readonly props: D
|
|
128
|
+
readonly on?: A
|
|
129
|
+
readonly contract: K
|
|
130
|
+
readonly render: ContractComponent<NoInfer<K>, LeafProps<D, A>>
|
|
131
|
+
readonly isLoading?: boolean
|
|
132
|
+
}
|
|
133
133
|
|
|
134
134
|
/**
|
|
135
135
|
* A BLOCK's presentational half. Two slots.
|
package/served-locale.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Rules for the SERVED LOCALE law - `patterns/
|
|
2
|
+
* Rules for the SERVED LOCALE law - `fe/canon/patterns/served-locale.md`.
|
|
3
3
|
*
|
|
4
4
|
* WHY THESE EXIST. An application obeyed the translation law completely - every label resolved in a
|
|
5
5
|
* connected half, no literal below a block, both catalogues in step - and served a Vietnamese reader
|
package/the-split.mjs
CHANGED
|
@@ -66,7 +66,7 @@ export const presentationalPurity = {
|
|
|
66
66
|
export const connectedBlockHasPresentationalTwin = {
|
|
67
67
|
meta: {
|
|
68
68
|
type: "problem",
|
|
69
|
-
docs: { description: "A connected block index renders only its exact `
|
|
69
|
+
docs: { description: "A connected block index renders only its exact `_X` twin from component.tsx." },
|
|
70
70
|
schema: [],
|
|
71
71
|
messages: {
|
|
72
72
|
missing:
|
|
@@ -81,7 +81,7 @@ export const connectedBlockHasPresentationalTwin = {
|
|
|
81
81
|
const block = connectedBlock(context.filename || context.getFilename())
|
|
82
82
|
if (!block) return {}
|
|
83
83
|
|
|
84
|
-
const twin =
|
|
84
|
+
const twin = `_${block}`
|
|
85
85
|
const worldBindings = new Set()
|
|
86
86
|
const rendered = []
|
|
87
87
|
let importsTwin = false
|
package/typography.mjs
CHANGED
|
@@ -1,83 +1,83 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The rule that holds `typography.md`.
|
|
3
|
-
*
|
|
4
|
-
* MOST OF THIS LAW IS A UNION, and what is left is one shape a type cannot see: a heading TAG
|
|
5
|
-
* written by hand. The level union stops a fifth step being asked for; nothing in the type system
|
|
6
|
-
* notices an `<h2>` in a file that never mentions the heading component at all.
|
|
7
|
-
*
|
|
8
|
-
* The rule's own twin lives in `tokens.mjs`: that one catches a heading assembled out of a large
|
|
9
|
-
* size and a heavy weight on some other element. Between them they cover both ways a heading gets
|
|
10
|
-
* hand-rolled - with the right tag and the wrong look, or the right look and no tag.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
/** Forward-slash form of a filename, so Windows paths compare like every other path. */
|
|
14
|
-
const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
|
|
15
|
-
|
|
16
|
-
/** Product source lives under `src/`; tooling and config are out of scope. */
|
|
17
|
-
const isSourceFile = (filename) => normalizePath(filename).includes("/src/")
|
|
18
|
-
|
|
19
|
-
/** A twin test may build heading markup by hand to assert against it. */
|
|
20
|
-
const isTestFile = (filename) => /\.(?:test|spec)\.(?:ts|tsx)$/.test(normalizePath(filename))
|
|
21
|
-
|
|
22
|
-
/** The one component that owns the tag and the size together, and therefore may write the tag. */
|
|
23
|
-
const isHeadingComponent = (filename) => normalizePath(filename).includes("/src/components/leaves/Heading/")
|
|
24
|
-
|
|
25
|
-
/** The tags a document outline is built from. */
|
|
26
|
-
const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"])
|
|
27
|
-
|
|
28
|
-
/** The deepest level the component draws; below this a page has nested too far. */
|
|
29
|
-
const DEEPEST_LEVEL = 4
|
|
30
|
-
|
|
31
|
-
/** Intrinsic (lowercase) tag name, or null for a component. */
|
|
32
|
-
const tagName = (opening) => {
|
|
33
|
-
const name = opening && opening.name
|
|
34
|
-
if (!name || name.type !== "JSXIdentifier") return null
|
|
35
|
-
return name.name === name.name.toLowerCase() ? name.name : null
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// -- TYPESET-1 · TYPESET-2 -------------------------------------------------------------------------------
|
|
39
|
-
|
|
40
|
-
/** A heading comes from the component that owns its tag and its size as one decision. */
|
|
41
|
-
export const noHeadingTagOutsideHeadingComponent = {
|
|
42
|
-
meta: {
|
|
43
|
-
type: "problem",
|
|
44
|
-
docs: { description: "Heading tags are written by the heading component, from a level." },
|
|
45
|
-
schema: [],
|
|
46
|
-
messages: {
|
|
47
|
-
tag:
|
|
48
|
-
"`<{{tag}}>` written here throws away the two facts the heading component exists to keep together: the tag a screen reader builds the document outline from, and the size a reader sees. Set separately they drift, and a screen's third-largest text becomes its first heading. Render the heading component with a level of {{level}}; one prop decides both, so they cannot disagree.",
|
|
49
|
-
tooDeep:
|
|
50
|
-
"`<{{tag}}>` is deeper than the scale goes: levels stop at {{deepest}}, because a fifth means the page has nested further than a reader can hold. That is a structure problem wearing a styling problem's clothes - flatten the section, then render the title with a level the scale has.",
|
|
51
|
-
},
|
|
52
|
-
},
|
|
53
|
-
create(context) {
|
|
54
|
-
const file = normalizePath(context.filename || context.getFilename())
|
|
55
|
-
if (!isSourceFile(file) || isTestFile(file) || isHeadingComponent(file)) return {}
|
|
56
|
-
return {
|
|
57
|
-
JSXOpeningElement(node) {
|
|
58
|
-
const tag = tagName(node)
|
|
59
|
-
if (!tag || !HEADING_TAGS.has(tag)) return
|
|
60
|
-
const level = Number(tag.slice(1))
|
|
61
|
-
if (level > DEEPEST_LEVEL) {
|
|
62
|
-
context.report({ node, messageId: "tooDeep", data: { tag, deepest: DEEPEST_LEVEL } })
|
|
63
|
-
return
|
|
64
|
-
}
|
|
65
|
-
context.report({ node, messageId: "tag", data: { tag, level } })
|
|
66
|
-
},
|
|
67
|
-
}
|
|
68
|
-
},
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** The rules this law contributes to the plugin. */
|
|
72
|
-
export const rules = {
|
|
73
|
-
"no-heading-tag-outside-heading-component": noHeadingTagOutsideHeadingComponent,
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* The level this law asks for, as the plugin's own opinion.
|
|
78
|
-
*
|
|
79
|
-
* Exact: a tag name in a file that is not the component that owns it. A repository adopting this
|
|
80
|
-
* with history should expect the deeper levels to report a structure problem rather than a styling
|
|
81
|
-
* one, and those take longer to fix than the message suggests.
|
|
82
|
-
*/
|
|
83
|
-
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
|
1
|
+
/**
|
|
2
|
+
* The rule that holds `typography.md`.
|
|
3
|
+
*
|
|
4
|
+
* MOST OF THIS LAW IS A UNION, and what is left is one shape a type cannot see: a heading TAG
|
|
5
|
+
* written by hand. The level union stops a fifth step being asked for; nothing in the type system
|
|
6
|
+
* notices an `<h2>` in a file that never mentions the heading component at all.
|
|
7
|
+
*
|
|
8
|
+
* The rule's own twin lives in `tokens.mjs`: that one catches a heading assembled out of a large
|
|
9
|
+
* size and a heavy weight on some other element. Between them they cover both ways a heading gets
|
|
10
|
+
* hand-rolled - with the right tag and the wrong look, or the right look and no tag.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Forward-slash form of a filename, so Windows paths compare like every other path. */
|
|
14
|
+
const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
|
|
15
|
+
|
|
16
|
+
/** Product source lives under `src/`; tooling and config are out of scope. */
|
|
17
|
+
const isSourceFile = (filename) => normalizePath(filename).includes("/src/")
|
|
18
|
+
|
|
19
|
+
/** A twin test may build heading markup by hand to assert against it. */
|
|
20
|
+
const isTestFile = (filename) => /\.(?:test|spec)\.(?:ts|tsx)$/.test(normalizePath(filename))
|
|
21
|
+
|
|
22
|
+
/** The one component that owns the tag and the size together, and therefore may write the tag. */
|
|
23
|
+
const isHeadingComponent = (filename) => normalizePath(filename).includes("/src/components/leaves/Heading/")
|
|
24
|
+
|
|
25
|
+
/** The tags a document outline is built from. */
|
|
26
|
+
const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"])
|
|
27
|
+
|
|
28
|
+
/** The deepest level the component draws; below this a page has nested too far. */
|
|
29
|
+
const DEEPEST_LEVEL = 4
|
|
30
|
+
|
|
31
|
+
/** Intrinsic (lowercase) tag name, or null for a component. */
|
|
32
|
+
const tagName = (opening) => {
|
|
33
|
+
const name = opening && opening.name
|
|
34
|
+
if (!name || name.type !== "JSXIdentifier") return null
|
|
35
|
+
return name.name === name.name.toLowerCase() ? name.name : null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// -- TYPESET-1 · TYPESET-2 -------------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/** A heading comes from the component that owns its tag and its size as one decision. */
|
|
41
|
+
export const noHeadingTagOutsideHeadingComponent = {
|
|
42
|
+
meta: {
|
|
43
|
+
type: "problem",
|
|
44
|
+
docs: { description: "Heading tags are written by the heading component, from a level." },
|
|
45
|
+
schema: [],
|
|
46
|
+
messages: {
|
|
47
|
+
tag:
|
|
48
|
+
"`<{{tag}}>` written here throws away the two facts the heading component exists to keep together: the tag a screen reader builds the document outline from, and the size a reader sees. Set separately they drift, and a screen's third-largest text becomes its first heading. Render the heading component with a level of {{level}}; one prop decides both, so they cannot disagree.",
|
|
49
|
+
tooDeep:
|
|
50
|
+
"`<{{tag}}>` is deeper than the scale goes: levels stop at {{deepest}}, because a fifth means the page has nested further than a reader can hold. That is a structure problem wearing a styling problem's clothes - flatten the section, then render the title with a level the scale has.",
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
create(context) {
|
|
54
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
55
|
+
if (!isSourceFile(file) || isTestFile(file) || isHeadingComponent(file)) return {}
|
|
56
|
+
return {
|
|
57
|
+
JSXOpeningElement(node) {
|
|
58
|
+
const tag = tagName(node)
|
|
59
|
+
if (!tag || !HEADING_TAGS.has(tag)) return
|
|
60
|
+
const level = Number(tag.slice(1))
|
|
61
|
+
if (level > DEEPEST_LEVEL) {
|
|
62
|
+
context.report({ node, messageId: "tooDeep", data: { tag, deepest: DEEPEST_LEVEL } })
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
context.report({ node, messageId: "tag", data: { tag, level } })
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The rules this law contributes to the plugin. */
|
|
72
|
+
export const rules = {
|
|
73
|
+
"no-heading-tag-outside-heading-component": noHeadingTagOutsideHeadingComponent,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The level this law asks for, as the plugin's own opinion.
|
|
78
|
+
*
|
|
79
|
+
* Exact: a tag name in a file that is not the component that owns it. A repository adopting this
|
|
80
|
+
* with history should expect the deeper levels to report a structure problem rather than a styling
|
|
81
|
+
* one, and those take longer to fix than the message suggests.
|
|
82
|
+
*/
|
|
83
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
package/vendor-boundary.mjs
CHANGED
|
@@ -27,15 +27,24 @@ const memberName = (node) => {
|
|
|
27
27
|
/** The component library this boundary owns. Its glyph counterpart belongs to `icon.mjs`. */
|
|
28
28
|
const VENDOR_PACKAGE_PREFIX = "@heroui/"
|
|
29
29
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
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)\//
|
|
33
42
|
|
|
34
43
|
/** Named surface branches may own the vendor wrapper they project a content contract into. */
|
|
35
44
|
const SURFACE_BRANCH = /\/src\/components\/branches\/(?:SurfaceCard|SurfaceAccordionCard|SurfaceListCard|SurfaceFormCard)\//
|
|
36
45
|
|
|
37
46
|
const isAllowedVendorOwner = (file) =>
|
|
38
|
-
file.includes("/src/components/leaves/") ||
|
|
47
|
+
file.includes("/src/components/leaves/") || SHELL_FILE.test(file) || SURFACE_BRANCH.test(file)
|
|
39
48
|
|
|
40
49
|
/** True for a file inside the component tree, where the boundary applies. */
|
|
41
50
|
const isComponentFile = (filename) => normalizePath(filename).includes("/src/components/")
|
|
@@ -56,15 +65,15 @@ const jsxMemberName = (node) => {
|
|
|
56
65
|
export const vendorBoundary = {
|
|
57
66
|
meta: {
|
|
58
67
|
type: "problem",
|
|
59
|
-
docs: { description: "Vendor imports belong to leaves and named
|
|
68
|
+
docs: { description: "Vendor imports belong to leaves, the two covering shells, and named surface branches." },
|
|
60
69
|
schema: [],
|
|
61
70
|
messages: {
|
|
62
71
|
outside:
|
|
63
|
-
"This component does not own a vendor primitive. Vendor imports belong to leaves,
|
|
64
|
-
|
|
65
|
-
"
|
|
66
|
-
|
|
67
|
-
"
|
|
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.",
|
|
68
77
|
},
|
|
69
78
|
},
|
|
70
79
|
create(context) {
|
|
@@ -72,8 +81,8 @@ export const vendorBoundary = {
|
|
|
72
81
|
// Outside the component tree, a provider standing the library up for the whole application is a
|
|
73
82
|
// different thing from a component pulling in a widget.
|
|
74
83
|
if (!isComponentFile(file)) return {}
|
|
75
|
-
const
|
|
76
|
-
const
|
|
84
|
+
const isShellDirectory = file.includes(SHELL_DIR)
|
|
85
|
+
const isShell = SHELL_FILE.test(file)
|
|
77
86
|
const isTestFile = /\.(?:test|spec)\.[jt]sx?$/.test(file)
|
|
78
87
|
const isAllowed = isAllowedVendorOwner(file)
|
|
79
88
|
let importsVendor = false
|
|
@@ -86,8 +95,14 @@ export const vendorBoundary = {
|
|
|
86
95
|
},
|
|
87
96
|
"Program:exit"(node) {
|
|
88
97
|
if (isTestFile) return
|
|
89
|
-
if (
|
|
90
|
-
|
|
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" })
|
|
91
106
|
},
|
|
92
107
|
}
|
|
93
108
|
},
|
|
@@ -95,22 +110,22 @@ export const vendorBoundary = {
|
|
|
95
110
|
|
|
96
111
|
// -- VENDOR-6 --------------------------------------------------------------------------------------
|
|
97
112
|
|
|
98
|
-
/**
|
|
99
|
-
export const
|
|
113
|
+
/** A content-agnostic modal shell owns one zero-inset vendor body as the scroll region. */
|
|
114
|
+
export const modalShellOwnsScrollBody = {
|
|
100
115
|
meta: {
|
|
101
116
|
type: "problem",
|
|
102
|
-
docs: { description: "
|
|
117
|
+
docs: { description: "ModalShell keeps one zero-inset Modal.Body as its scroll region." },
|
|
103
118
|
schema: [],
|
|
104
119
|
messages: {
|
|
105
120
|
missing:
|
|
106
|
-
"
|
|
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.",
|
|
107
122
|
inset:
|
|
108
|
-
"
|
|
123
|
+
"ModalShell's body must be `className=\"p-0\"`. The mounted contract owns layout; vendor body padding plus contract padding creates a doubled inset.",
|
|
109
124
|
},
|
|
110
125
|
},
|
|
111
126
|
create(context) {
|
|
112
127
|
const file = normalizePath(context.filename || context.getFilename())
|
|
113
|
-
if (!/\/src\/components\/
|
|
128
|
+
if (!/\/src\/components\/shells\/ModalShell\/index\.tsx$/.test(file)) return {}
|
|
114
129
|
let hasBody = false
|
|
115
130
|
return {
|
|
116
131
|
JSXOpeningElement(node) {
|
|
@@ -280,24 +295,24 @@ export const textLinkUsesHeroLink = {
|
|
|
280
295
|
|
|
281
296
|
// -- VENDOR-11 -------------------------------------------------------------------------------------
|
|
282
297
|
|
|
283
|
-
/** The account block owns the product choices;
|
|
298
|
+
/** The account block owns the product choices; DropdownShell alone owns HeroUI mechanics. */
|
|
284
299
|
export const accountControlOwnsDropdown = {
|
|
285
300
|
meta: {
|
|
286
301
|
type: "problem",
|
|
287
|
-
docs: { description: "AccountMenu is a block over
|
|
302
|
+
docs: { description: "AccountMenu is a block over DropdownShell; only the shell imports HeroUI Dropdown." },
|
|
288
303
|
schema: [],
|
|
289
304
|
messages: {
|
|
290
|
-
dropdown: "
|
|
291
|
-
|
|
292
|
-
vendor: "AccountMenu is a product block. Import
|
|
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.",
|
|
293
308
|
menu: "ShellNav must render the AccountMenu block from `components/blocks/auth/AccountMenu`.",
|
|
294
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.",
|
|
295
|
-
pieces: "AccountMenu passes typed section data to
|
|
310
|
+
pieces: "AccountMenu passes typed section data to DropdownShell; it must not import shell Section or Item pieces and rebuild vendor anatomy itself.",
|
|
296
311
|
},
|
|
297
312
|
},
|
|
298
313
|
create(context) {
|
|
299
314
|
const file = normalizePath(context.filename || context.getFilename())
|
|
300
|
-
const isDropdown = /\/src\/components\/
|
|
315
|
+
const isDropdown = /\/src\/components\/shells\/DropdownShell\/index\.tsx$/.test(file)
|
|
301
316
|
const isMenu = /\/src\/components\/blocks\/auth\/AccountMenu\/component\.tsx$/.test(file)
|
|
302
317
|
const isNav = /\/src\/components\/layouts\/ShellNav\/component\.tsx$/.test(file)
|
|
303
318
|
if (!isDropdown && !isMenu && !isNav) return {}
|
|
@@ -306,7 +321,7 @@ export const accountControlOwnsDropdown = {
|
|
|
306
321
|
ExportNamedDeclaration(node) {
|
|
307
322
|
if (!isDropdown) return
|
|
308
323
|
const source = context.sourceCode || context.getSourceCode()
|
|
309
|
-
|
|
324
|
+
if (/\bDropdownShell(?:Item|Section)\b/.test(source.getText(node))) {
|
|
310
325
|
context.report({ node, messageId: "pieces" })
|
|
311
326
|
}
|
|
312
327
|
},
|
|
@@ -315,10 +330,10 @@ export const accountControlOwnsDropdown = {
|
|
|
315
330
|
hasOwner ||= (node.specifiers || []).some((specifier) => specifier.imported?.name === "Dropdown")
|
|
316
331
|
}
|
|
317
332
|
if (isMenu && node.source?.value === "@heroui/react") context.report({ node, messageId: "vendor" })
|
|
318
|
-
if (isMenu && /\/components\/
|
|
333
|
+
if (isMenu && /\/components\/shells\/DropdownShell$/.test(normalizePath(node.source?.value))) {
|
|
319
334
|
hasOwner = true
|
|
320
335
|
if ((node.specifiers || []).some((specifier) =>
|
|
321
|
-
/^(?:
|
|
336
|
+
/^(?:DropdownShellSection|DropdownShellItem)/.test(specifier.imported?.name || ""))) {
|
|
322
337
|
context.report({ node, messageId: "pieces" })
|
|
323
338
|
}
|
|
324
339
|
}
|
|
@@ -335,7 +350,7 @@ export const accountControlOwnsDropdown = {
|
|
|
335
350
|
},
|
|
336
351
|
"Program:exit"(node) {
|
|
337
352
|
if (hasOwner) return
|
|
338
|
-
context.report({ node, messageId: isDropdown ? "dropdown" : isMenu ? "
|
|
353
|
+
context.report({ node, messageId: isDropdown ? "dropdown" : isMenu ? "shell" : "menu" })
|
|
339
354
|
},
|
|
340
355
|
}
|
|
341
356
|
},
|
|
@@ -440,12 +455,12 @@ export const noInternalStarciHref = {
|
|
|
440
455
|
export const authOverlayOwnsSingleContentHost = {
|
|
441
456
|
meta: {
|
|
442
457
|
type: "problem",
|
|
443
|
-
docs: { description: "Authentication has one zero-inset content host inside
|
|
458
|
+
docs: { description: "Authentication has one zero-inset content host inside ModalShell." },
|
|
444
459
|
schema: [],
|
|
445
460
|
messages: {
|
|
446
|
-
duplicate: "SignInOverlay must project
|
|
447
|
-
missing: "SignInOverlay must
|
|
448
|
-
inset: "`centred-page-column` must not add py/pt/pb.
|
|
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.",
|
|
449
464
|
},
|
|
450
465
|
},
|
|
451
466
|
create(context) {
|
|
@@ -454,63 +469,24 @@ export const authOverlayOwnsSingleContentHost = {
|
|
|
454
469
|
const isContracts = /\/src\/components\/contracts\/index\.ts$/.test(file)
|
|
455
470
|
if (!isOverlay && !isContracts) return {}
|
|
456
471
|
let hasContent = false
|
|
457
|
-
|
|
458
|
-
/*
|
|
459
|
-
* Two call shapes satisfy the same invariant - one contract-driven content host, zero extra
|
|
460
|
-
* vertical inset - and both are recognised here.
|
|
461
|
-
*
|
|
462
|
-
* (a) THE OLD SHAPE: the overlay imports `ContractContent` and mounts it itself as the branch's
|
|
463
|
-
* children. `ContractContent` IS the checked host, so importing it is enough to credit the
|
|
464
|
-
* overlay with owning one.
|
|
465
|
-
*
|
|
466
|
-
* (b) THE NEW SHAPE: `ModalBranch`/`DrawerBranch` were migrated to take `{contract, render}`
|
|
467
|
-
* directly and build `<Tree contract render />` internally (SLOTS-4). An overlay that hands both
|
|
468
|
-
* props straight to the branch has delegated the same host to the branch itself; there is
|
|
469
|
-
* nothing left for the overlay to import.
|
|
470
|
-
*
|
|
471
|
-
* Matched by JSX ATTRIBUTE NAME, not by where `ModalBranch`/`DrawerBranch` resolve from - same
|
|
472
|
-
* reasoning VENDOR-12 already applies to `ContractContent`/`Tree` below: canon vocabulary means
|
|
473
|
-
* the same thing wherever it is imported from.
|
|
474
|
-
*/
|
|
475
|
-
const MECHANICS_BRANCH_NAMES = new Set(["ModalBranch", "DrawerBranch"])
|
|
476
|
-
const hasJsxAttribute = (attributes, attrName) =>
|
|
477
|
-
(attributes || []).some((attribute) =>
|
|
478
|
-
attribute.type === "JSXAttribute"
|
|
479
|
-
&& attribute.name?.type === "JSXIdentifier"
|
|
480
|
-
&& attribute.name.name === attrName)
|
|
481
|
-
const jsxChildName = (child) => {
|
|
482
|
-
if (child?.type !== "JSXElement") return null
|
|
483
|
-
const name = child.openingElement?.name
|
|
484
|
-
return name?.type === "JSXIdentifier" ? name.name : null
|
|
485
|
-
}
|
|
486
|
-
|
|
487
472
|
return {
|
|
488
473
|
ImportDeclaration(node) {
|
|
489
474
|
if (!isOverlay) return
|
|
490
475
|
/*
|
|
491
|
-
* Matched by the NAME imported, not by where it came from
|
|
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.
|
|
492
484
|
*/
|
|
493
485
|
for (const specifier of node.specifiers || []) {
|
|
494
486
|
if (specifier.imported?.name === "ContractContent") hasContent = true
|
|
495
|
-
// Opening `Tree` by hand is the one thing neither shape ever does: both shapes close the
|
|
496
|
-
// host either inside ContractContent or inside the mechanics branch itself.
|
|
497
487
|
if (specifier.imported?.name === "Tree") context.report({ node: specifier, messageId: "duplicate" })
|
|
498
488
|
}
|
|
499
489
|
},
|
|
500
|
-
JSXElement(node) {
|
|
501
|
-
if (!isOverlay) return
|
|
502
|
-
const name = node.openingElement?.name
|
|
503
|
-
if (name?.type !== "JSXIdentifier" || !MECHANICS_BRANCH_NAMES.has(name.name)) return
|
|
504
|
-
const attributes = node.openingElement.attributes
|
|
505
|
-
if (!hasJsxAttribute(attributes, "contract") || !hasJsxAttribute(attributes, "render")) return
|
|
506
|
-
// The branch itself now owns the host: shape (b).
|
|
507
|
-
hasContent = true
|
|
508
|
-
// A second content host nested as children, when the branch already receives contract/render
|
|
509
|
-
// directly, duplicates the host the branch already opened.
|
|
510
|
-
const nestedHost = (node.children || []).some((child) =>
|
|
511
|
-
jsxChildName(child) === "Tree" || jsxChildName(child) === "ContractContent")
|
|
512
|
-
if (nestedHost) context.report({ node, messageId: "duplicate" })
|
|
513
|
-
},
|
|
514
490
|
Property(node) {
|
|
515
491
|
if (!isContracts) return
|
|
516
492
|
const key = node.key?.type === "Literal" ? node.key.value : node.key?.name
|
|
@@ -528,7 +504,7 @@ export const authOverlayOwnsSingleContentHost = {
|
|
|
528
504
|
/** The rules this law contributes to the plugin. */
|
|
529
505
|
export const rules = {
|
|
530
506
|
"vendor-boundary": vendorBoundary,
|
|
531
|
-
"modal-
|
|
507
|
+
"modal-shell-owns-scroll-body": modalShellOwnsScrollBody,
|
|
532
508
|
"field-input-uses-secondary-variant": fieldInputUsesSecondaryVariant,
|
|
533
509
|
"field-label-is-text-only": fieldLabelIsTextOnly,
|
|
534
510
|
"no-surface-branch-in-overlay": noSurfaceBranchInOverlay,
|
|
@@ -543,7 +519,8 @@ export const rules = {
|
|
|
543
519
|
* The level this law asks for, as the plugin's own opinion.
|
|
544
520
|
*
|
|
545
521
|
* A repository adopting this with history should expect the outward half to report and the inward
|
|
546
|
-
* half to be silent
|
|
547
|
-
*
|
|
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.
|
|
548
525
|
*/
|
|
549
526
|
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|