@starci/eslint-grammar-core 0.13.0 → 0.14.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/CHANGELOG.md +12 -0
- package/README.md +4 -1
- package/dist/app-shell.d.ts +15 -0
- package/dist/app-shell.js +224 -0
- package/dist/chip-label.d.ts +14 -0
- package/dist/chip-label.js +117 -0
- package/dist/drawing.d.ts +8 -0
- package/dist/drawing.js +59 -0
- package/dist/grammar-boundary.d.ts +12 -0
- package/dist/grammar-boundary.js +88 -0
- package/dist/icon.d.ts +27 -0
- package/dist/icon.js +293 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +73 -0
- package/dist/lib/app-shell-cluster.d.ts +6 -0
- package/dist/lib/app-shell-cluster.js +75 -0
- package/dist/lib/app-shell-create.d.ts +5 -0
- package/dist/lib/app-shell-create.js +37 -0
- package/dist/lib/app-shell-owner-source.d.ts +11 -0
- package/dist/lib/app-shell-owner-source.js +159 -0
- package/dist/lib/app-shell-owner.d.ts +7 -0
- package/dist/lib/app-shell-owner.js +97 -0
- package/dist/lib/app-shell-scope.d.ts +13 -0
- package/dist/lib/app-shell-scope.js +93 -0
- package/dist/lib/app-shell-visible.d.ts +13 -0
- package/dist/lib/app-shell-visible.js +117 -0
- package/dist/lib/app-shell.d.ts +27 -0
- package/dist/lib/app-shell.js +185 -0
- package/dist/lib/rule-contract.d.ts +12 -0
- package/dist/lib/rule-contract.js +23 -0
- package/dist/lib/status-tones.generated.d.ts +2 -0
- package/dist/lib/status-tones.generated.js +8 -0
- package/dist/lib/types.d.ts +113 -0
- package/dist/lib/types.js +2 -0
- package/dist/lib/why.d.ts +8 -0
- package/dist/lib/why.js +42 -0
- package/dist/menu-entries.d.ts +27 -0
- package/dist/menu-entries.js +142 -0
- package/dist/status-colors.d.ts +13 -0
- package/dist/status-colors.js +143 -0
- package/dist/tokens.d.ts +11 -0
- package/dist/tokens.js +166 -0
- package/dist/typography.d.ts +13 -0
- package/dist/typography.js +72 -0
- package/dist/vendor-boundary.d.ts +5 -0
- package/dist/vendor-boundary.js +25 -0
- package/docs/chip-label.md +30 -0
- package/docs/grammar-boundary.md +1 -1
- package/docs/status-colors.md +2 -2
- package/package.json +18 -15
- package/runtime/knowledge/hfs/canon-pins.yaml +11 -10
- package/runtime/knowledge/hfs/rules.yaml +23 -0
- package/app-shell.mjs +0 -208
- package/drawing.mjs +0 -60
- package/grammar-boundary.mjs +0 -94
- package/icon.mjs +0 -313
- package/index.d.ts +0 -28
- package/index.mjs +0 -78
- package/lib/app-shell-cluster.mjs +0 -66
- package/lib/app-shell-create.mjs +0 -33
- package/lib/app-shell-owner-source.mjs +0 -124
- package/lib/app-shell-owner.mjs +0 -83
- package/lib/app-shell-scope.mjs +0 -79
- package/lib/app-shell-visible.mjs +0 -92
- package/lib/app-shell.mjs +0 -148
- package/lib/rule-contract.mjs +0 -11
- package/lib/status-tones.generated.mjs +0 -9
- package/lib/why.mjs +0 -38
- package/menu-entries.mjs +0 -146
- package/status-colors.mjs +0 -140
- package/tokens.mjs +0 -164
- package/typography.mjs +0 -81
- package/vendor-boundary.mjs +0 -29
package/lib/app-shell-owner.mjs
DELETED
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
/** Imported shell-band delegation requires its canonical composition in every visible source alternative. */
|
|
2
|
-
import { functionOf, importOf } from "@starci/eslint-canon-fe/lib/bindings.mjs"
|
|
3
|
-
import { isBarControl, propOf, resolveLocal, textProp } from "./app-shell.mjs"
|
|
4
|
-
import { isNavigationDrawer } from "./app-shell-scope.mjs"
|
|
5
|
-
import { visitFunction, visitVisible } from "./app-shell-visible.mjs"
|
|
6
|
-
import { headerProofs, importedHeader } from "./app-shell-owner-source.mjs"
|
|
7
|
-
|
|
8
|
-
const OVERLAYS = new Set(["Dialog", "AlertDialog", "Drawer", "Popover", "DropdownMenu", "Tooltip"])
|
|
9
|
-
|
|
10
|
-
const importBinding = (context, opening) => {
|
|
11
|
-
const name = opening.name.type === "JSXMemberExpression" ? opening.name.object : opening.name
|
|
12
|
-
return name.type === "JSXIdentifier" ? importOf(context, name) : null
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const reactFragment = (context, opening) => {
|
|
16
|
-
const imported = importBinding(context, opening)
|
|
17
|
-
return imported?.source === "react" && (imported.imported === "Fragment" || opening.name.type === "JSXMemberExpression" && opening.name.property.name === "Fragment")
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const proveOwner = (owner, seen, kind) => {
|
|
21
|
-
if (!owner) return false
|
|
22
|
-
const { context, value } = owner
|
|
23
|
-
const key = `${kind}:${context.filename}:${value.range?.join(":")}`
|
|
24
|
-
const proofs = headerProofs(context)
|
|
25
|
-
if (proofs.has(key)) return proofs.get(key)
|
|
26
|
-
if (seen.has(key)) return false
|
|
27
|
-
const fn = functionOf(context, resolveLocal(context, value))
|
|
28
|
-
const valid = Boolean(fn) && proveComposition(context, (visit, branch) => visitFunction(context, fn, visit, new Set(), branch, []), new Set(seen).add(key), kind)
|
|
29
|
-
proofs.set(key, valid)
|
|
30
|
-
return valid
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Drawer bodies must expose navigation on every branch, outside nested overlay content. */
|
|
34
|
-
const proveDrawerBody = (context, opening, seen) => proveComposition(context, (visit, branch) => {
|
|
35
|
-
const children = opening.parent.children.filter((child) => child.type !== "JSXText" || child.value.trim())
|
|
36
|
-
if (children.length) for (const child of children) visitVisible(context, child, visit, new Set(), branch)
|
|
37
|
-
else visitVisible(context, propOf(context, opening, "children").value, visit, new Set(), branch)
|
|
38
|
-
}, seen, "sidebar")
|
|
39
|
-
|
|
40
|
-
const matchesComposition = (context, opening, name, seen, kind) => {
|
|
41
|
-
if (kind === "drawer") return isNavigationDrawer(context, opening) && proveDrawerBody(context, opening, seen)
|
|
42
|
-
if (kind === "sidebar") return name === "Sidebar" && textProp(context, opening, "presentation") === "drawer"
|
|
43
|
-
return ["TopBar", "NavigationFeatureNav"].includes(name)
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const proveComposition = (context, render, seen, kind) => {
|
|
47
|
-
const pending = [new Map()]
|
|
48
|
-
while (pending.length) {
|
|
49
|
-
const choices = pending.pop()
|
|
50
|
-
const branch = []
|
|
51
|
-
let found = false
|
|
52
|
-
let unknown = false
|
|
53
|
-
render((opening, name) => {
|
|
54
|
-
if (reactFragment(context, opening)) return
|
|
55
|
-
if (matchesComposition(context, opening, name, seen, kind)) { found = true; return false }
|
|
56
|
-
if (OVERLAYS.has(name)) return false
|
|
57
|
-
if (kind === "bar" && (["header", "nav", "main"].includes(name) || isBarControl(context, opening, name))) { unknown = true; return false }
|
|
58
|
-
if (name === null && importBinding(context, opening)) {
|
|
59
|
-
if (proveOwner(importedHeader(context, opening), seen, kind)) found = true
|
|
60
|
-
else unknown = true
|
|
61
|
-
return false
|
|
62
|
-
}
|
|
63
|
-
}, (node) => {
|
|
64
|
-
if (!choices.has(node)) branch.push(node)
|
|
65
|
-
return choices.get(node) ?? "consequent"
|
|
66
|
-
})
|
|
67
|
-
const missing = branch.find((node) => !choices.has(node))
|
|
68
|
-
if (missing) pending.push(new Map([...choices, [missing, "alternate"]]), new Map([...choices, [missing, "consequent"]]))
|
|
69
|
-
else if (!found || unknown) return false
|
|
70
|
-
}
|
|
71
|
-
return true
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** True only for an imported JSX header whose owned implementation reaches a canonical Grammar bar. */
|
|
75
|
-
export const provesImportedBar = (context, opening) => {
|
|
76
|
-
if (reactFragment(context, opening)) return null
|
|
77
|
-
if (!importBinding(context, opening)) return null
|
|
78
|
-
return proveOwner(importedHeader(context, opening), new Set(), "bar")
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** True only when every visible compact-header alternative reaches a left Drawer with a drawer Sidebar. */
|
|
82
|
-
export const provesNavigationOpener = (context, expression) => proveComposition(context,
|
|
83
|
-
(visit, branch) => visitVisible(context, expression, visit, new Set(), branch), new Set(), "drawer")
|
package/lib/app-shell-scope.mjs
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
/** Structural app-shell ownership, distinct from generic flow-based site chrome. */
|
|
2
|
-
import { walk } from "@starci/eslint-canon-fe/lib/ast.mjs"
|
|
3
|
-
import { kindOfFile, roleOfFile } from "@starci/eslint-canon-fe/lib/scope.mjs"
|
|
4
|
-
import { grammarName, propOf, textProp } from "./app-shell.mjs"
|
|
5
|
-
import { visitVisible } from "./app-shell-visible.mjs"
|
|
6
|
-
|
|
7
|
-
/** A workspace identity or a sidebar/navigation slot identifies an application shell structurally. */
|
|
8
|
-
export const isAppShell = (context, opening) => {
|
|
9
|
-
if (grammarName(context, opening) !== "WorkspaceShell") return false
|
|
10
|
-
const viewport = textProp(context, opening, "viewport")
|
|
11
|
-
if (viewport === "fixed" || viewport !== "flow" && ownsAppFrame(context, opening)) return true
|
|
12
|
-
const navigation = propOf(context, opening, "navigation")
|
|
13
|
-
if (navigation.value !== null || !navigation.known) return true
|
|
14
|
-
let identity = false
|
|
15
|
-
visitVisible(context, propOf(context, opening, "header").value, (_node, name) => {
|
|
16
|
-
if (name === "IdentityLockup") identity = true
|
|
17
|
-
})
|
|
18
|
-
return identity
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** A left Drawer containing a drawer Sidebar is the mobile navigation opener, including an extracted client owner. */
|
|
22
|
-
export const isNavigationDrawer = (context, opening) => {
|
|
23
|
-
if (grammarName(context, opening) !== "Drawer" || textProp(context, opening, "placement") !== "left") return false
|
|
24
|
-
let sidebar = false
|
|
25
|
-
const inspect = (node, name) => {
|
|
26
|
-
if (name === "Sidebar" && textProp(context, node, "presentation") === "drawer") sidebar = true
|
|
27
|
-
}
|
|
28
|
-
for (const child of opening.parent.children) visitVisible(context, child, inspect)
|
|
29
|
-
visitVisible(context, propOf(context, opening, "children").value, inspect)
|
|
30
|
-
return sidebar
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Shell-named React owners and the HFS layout kind compose an app frame, rather than page-local navigation. */
|
|
34
|
-
export const ownsAppFrame = (context, node) => {
|
|
35
|
-
if (kindOfFile(context) === "layouts" || roleOfFile(context) === "layout") return true
|
|
36
|
-
let rendered = null
|
|
37
|
-
let shellName = false
|
|
38
|
-
for (let current = node.parent; current; current = current.parent) {
|
|
39
|
-
if (current.type === "ReturnStatement") rendered = current.argument
|
|
40
|
-
if (current.type === "FunctionDeclaration") { shellName = /Shell(?:Base)?$/.test(current.id?.name ?? ""); break }
|
|
41
|
-
if (current.type === "ArrowFunctionExpression" || current.type === "FunctionExpression") {
|
|
42
|
-
rendered ??= current.body
|
|
43
|
-
shellName = current.parent?.type === "VariableDeclarator" && /Shell(?:Base)?$/.test(current.parent.id.name ?? "")
|
|
44
|
-
break
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
if (shellName) return true
|
|
48
|
-
let sidebar = false
|
|
49
|
-
visitVisible(context, rendered, (_opening, name) => { if (name === "Sidebar") sidebar = true })
|
|
50
|
-
return sidebar
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const APP_BAR_CACHE = new WeakMap()
|
|
54
|
-
|
|
55
|
-
/** Every visible opening hosted by a structurally identified app shell in this source file. */
|
|
56
|
-
export const appBarOpenings = (context) => {
|
|
57
|
-
const source = context.sourceCode
|
|
58
|
-
if (APP_BAR_CACHE.has(source)) return APP_BAR_CACHE.get(source)
|
|
59
|
-
const openings = new Set()
|
|
60
|
-
walk(source, source.ast, (node) => {
|
|
61
|
-
if (node.type !== "JSXOpeningElement" || !isAppShell(context, node)) return
|
|
62
|
-
for (const slot of ["header", "compactHeader"]) {
|
|
63
|
-
visitVisible(context, propOf(context, node, slot).value, (opening) => { openings.add(opening) })
|
|
64
|
-
}
|
|
65
|
-
})
|
|
66
|
-
APP_BAR_CACHE.set(source, openings)
|
|
67
|
-
return openings
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** Grammar bars are app bars unless an explicit generic flow shell establishes site-chrome ownership. */
|
|
71
|
-
export const isAppBar = (context, opening) => {
|
|
72
|
-
if (!["TopBar", "NavigationFeatureNav"].includes(grammarName(context, opening))) return false
|
|
73
|
-
for (let current = opening.parent; current; current = current.parent) {
|
|
74
|
-
if (current.type !== "JSXElement") continue
|
|
75
|
-
const shell = current.openingElement
|
|
76
|
-
if (grammarName(context, shell) === "WorkspaceShell" && textProp(context, shell, "viewport") === "flow" && !isAppShell(context, shell)) return false
|
|
77
|
-
}
|
|
78
|
-
return true
|
|
79
|
-
}
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
/** Traverse only the visible bar composition and its local React owners. */
|
|
2
|
-
import { functionOf, variableOf } from "@starci/eslint-canon-fe/lib/bindings.mjs"
|
|
3
|
-
import { grammarName, propOf, resolveLocal } from "./app-shell.mjs"
|
|
4
|
-
|
|
5
|
-
const BAR_SLOTS = {
|
|
6
|
-
WorkspaceShell: ["header", "compactHeader"],
|
|
7
|
-
TopBar: ["brand", "navigation", "actions"],
|
|
8
|
-
NavigationFeatureNav: ["identity", "navigation", "actions", "compactNavigationTrigger"],
|
|
9
|
-
DropdownMenu: ["trigger"],
|
|
10
|
-
Popover: ["trigger"],
|
|
11
|
-
Drawer: ["trigger"],
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/** Visible JSX composition only: menu, popover and drawer bodies are outside the navbar band. */
|
|
15
|
-
export const visitVisible = (context, expression, visit, seen = new Set(), branch = () => "both", ancestors = []) => {
|
|
16
|
-
const node = resolveLocal(context, expression)
|
|
17
|
-
if (!node || seen.has(node)) return
|
|
18
|
-
const visited = new Set(seen).add(node)
|
|
19
|
-
const next = (child) => visitVisible(context, child, visit, visited, branch, ancestors)
|
|
20
|
-
switch (node.type) {
|
|
21
|
-
case "JSXExpressionContainer": next(node.expression); break
|
|
22
|
-
case "JSXFragment": for (const child of node.children) next(child); break
|
|
23
|
-
case "ConditionalExpression": visitConditional(node, next, branch); break
|
|
24
|
-
case "LogicalExpression": visitLogical(context, node, next, branch); break
|
|
25
|
-
case "ArrayExpression": for (const item of node.elements) next(item); break
|
|
26
|
-
case "JSXElement": visitElement(context, node, visit, visited, branch, ancestors); break
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const visitConditional = (node, next, branch) => {
|
|
31
|
-
const choice = branch(node)
|
|
32
|
-
if (choice !== "alternate") next(node.consequent)
|
|
33
|
-
if (choice !== "consequent") next(node.alternate)
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const visitLogical = (context, node, next, branch) => {
|
|
37
|
-
const left = resolveLocal(context, node.left)
|
|
38
|
-
const known = left?.type === "Literal"
|
|
39
|
-
let right = node.operator === "&&" ? Boolean(left?.value) : !left?.value
|
|
40
|
-
if (node.operator === "??") right = left?.value === null
|
|
41
|
-
if (!known) {
|
|
42
|
-
const choice = branch(node)
|
|
43
|
-
if (choice === "both") { next(node.left); next(node.right); return }
|
|
44
|
-
right = choice === "consequent"
|
|
45
|
-
}
|
|
46
|
-
next(right ? node.right : node.left)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const visitElement = (context, node, visit, seen, branch, ancestors) => {
|
|
50
|
-
const opening = node.openingElement
|
|
51
|
-
const name = grammarName(context, opening)
|
|
52
|
-
if (visit(opening, name, ancestors) === false) return
|
|
53
|
-
const inside = [...ancestors, opening]
|
|
54
|
-
const next = (child) => visitVisible(context, child, visit, seen, branch, inside)
|
|
55
|
-
if (name === null && opening.name.type === "JSXIdentifier") {
|
|
56
|
-
const fn = functionOf(context, resolveLocal(context, opening.name)) ?? variableOf(context, opening.name)?.defs.find((definition) => definition.type === "FunctionName")?.node
|
|
57
|
-
if (fn) visitFunction(context, fn, visit, seen, branch, inside)
|
|
58
|
-
}
|
|
59
|
-
const slots = BAR_SLOTS[name]
|
|
60
|
-
if (slots) for (const slot of slots) next(propOf(context, opening, slot).value)
|
|
61
|
-
if (["DropdownMenu", "Popover", "Drawer"].includes(name)) return
|
|
62
|
-
for (const child of node.children) next(child)
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** Visit a rendered owner's returns with the same branch, overlay and local-binding semantics as inline JSX. */
|
|
66
|
-
export const visitFunction = (context, fn, visit, seen, branch, ancestors) => {
|
|
67
|
-
if (fn.body.type !== "BlockStatement") return visitVisible(context, fn.body, visit, seen, branch, ancestors)
|
|
68
|
-
visitStatements(context, fn.body.body, visit, seen, branch, ancestors)
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const visitStatements = (context, statements, visit, seen, branch, ancestors) => {
|
|
72
|
-
for (const statement of statements) {
|
|
73
|
-
if (statement.type === "ReturnStatement") {
|
|
74
|
-
visitVisible(context, statement.argument, visit, seen, branch, ancestors)
|
|
75
|
-
return true
|
|
76
|
-
}
|
|
77
|
-
if (statement.type === "BlockStatement" && visitStatements(context, statement.body, visit, seen, branch, ancestors)) return true
|
|
78
|
-
if (statement.type === "IfStatement") {
|
|
79
|
-
if (visitIf(context, statement, visit, seen, branch, ancestors)) return true
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
return false
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const visitIf = (context, statement, visit, seen, branch, ancestors) => {
|
|
86
|
-
const condition = resolveLocal(context, statement.test)
|
|
87
|
-
let choice = branch(statement)
|
|
88
|
-
if (condition?.type === "Literal") choice = condition.value ? "consequent" : "alternate"
|
|
89
|
-
const consequent = choice !== "alternate" && visitStatements(context, [statement.consequent], visit, seen, branch, ancestors)
|
|
90
|
-
const alternate = choice !== "consequent" && statement.alternate && visitStatements(context, [statement.alternate], visit, seen, branch, ancestors)
|
|
91
|
-
return choice === "consequent" && consequent || choice === "alternate" && alternate || consequent && alternate
|
|
92
|
-
}
|
package/lib/app-shell.mjs
DELETED
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
/** Static readers for the visible Grammar app-bar composition, including local JSX owners. */
|
|
2
|
-
import { attributeValue, stringOf } from "@starci/eslint-canon-fe/lib/ast.mjs"
|
|
3
|
-
import { functionOf, importOf, unwrap, variableOf } from "@starci/eslint-canon-fe/lib/bindings.mjs"
|
|
4
|
-
|
|
5
|
-
const GRAMMAR_SOURCE = /^@starci\/grammar(?:\/|$)/
|
|
6
|
-
/** Grammar controls whose visible hit target belongs to the navbar band's size contract. */
|
|
7
|
-
const BAR_CONTROLS = new Set(["Button", "IconButton", "Switch", "AvatarButton", "PlainPressable", "IdentityLockup", "SearchField", "Input", "TextField", "Select", "TextAction"])
|
|
8
|
-
const BAR_WRAPPERS = new Set(["GrammarRoot", "WorkspaceShell", "TopBar", "NavigationFeatureNav", "Region", "PageContainer", "NavLandmark", "Form", "Fieldset", "Field", "ButtonGroup", "DropdownMenu", "Popover", "Drawer", "Tooltip"])
|
|
9
|
-
const ACTION_PROPS = ["onPress", "onClick", "onAction", "onValueChange", "onSelectionChange", "onSelectedChange", "onChange", "onInputChange"]
|
|
10
|
-
const INTERACTION_PROPS = new Set(["label", "value", "defaultValue", "selectedKey", "selectedKeys", "selectionMode", "href", ...ACTION_PROPS])
|
|
11
|
-
|
|
12
|
-
/** Bar policy also counts labelled/value/selection/action field owners, so an extra control cannot vanish from the cluster. */
|
|
13
|
-
export const isBarControl = (context, opening, name) => {
|
|
14
|
-
if (BAR_CONTROLS.has(name)) return true
|
|
15
|
-
if (!name || !/^[A-Z]/.test(name) || BAR_WRAPPERS.has(name)) return false
|
|
16
|
-
if (opening.attributes.some((entry) => entry.type === "JSXAttribute" && INTERACTION_PROPS.has(entry.name.name))) return true
|
|
17
|
-
return [...INTERACTION_PROPS].some((prop) => propOf(context, opening, prop).value !== null)
|
|
18
|
-
}
|
|
19
|
-
/** A literal or constant's initializer, without type-only wrappers. */
|
|
20
|
-
export const resolveLocal = (context, expression, seen = new Set()) => {
|
|
21
|
-
const node = unwrap(expression)
|
|
22
|
-
if (node?.type === "MemberExpression") return resolveMember(context, node, seen)
|
|
23
|
-
if (node?.type === "CallExpression") return resolveCall(context, node, seen)
|
|
24
|
-
if (!["Identifier", "JSXIdentifier"].includes(node?.type) || seen.has(node)) return node
|
|
25
|
-
const definition = variableOf(context, node)?.defs.find((entry) => entry.type === "Variable")
|
|
26
|
-
if (!definition || definition.parent?.kind !== "const") return node
|
|
27
|
-
seen.add(node)
|
|
28
|
-
return resolveLocal(context, initializerOf(definition.node, node), seen)
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const resolveCall = (context, node, seen) => {
|
|
32
|
-
if (seen.has(node)) return node
|
|
33
|
-
seen.add(node)
|
|
34
|
-
const fn = functionOf(context, resolveLocal(context, node.callee, seen))
|
|
35
|
-
if (!fn) return node
|
|
36
|
-
if (fn.body.type !== "BlockStatement") return resolveLocal(context, fn.body, seen)
|
|
37
|
-
if (fn.body.body.some((statement) => !["VariableDeclaration", "ReturnStatement"].includes(statement.type))) return node
|
|
38
|
-
const returned = fn.body.body.find((statement) => statement.type === "ReturnStatement")
|
|
39
|
-
return returned ? resolveLocal(context, returned.argument, seen) : node
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const resolveMember = (context, node, seen) => {
|
|
43
|
-
const key = node.computed ? stringOf(node.property) : node.property.name
|
|
44
|
-
if (!key) return node
|
|
45
|
-
const property = objectValue(context, node.object, key, seen)
|
|
46
|
-
return property.known && property.value !== null ? resolveLocal(context, property.value, seen) : node
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const initializerOf = (declaration, node) => {
|
|
50
|
-
if (declaration.id.type === "ObjectPattern") {
|
|
51
|
-
const property = declaration.id.properties.find((entry) => entry.type === "Property" && entry.value.name === node.name)
|
|
52
|
-
if (!property) return node
|
|
53
|
-
return { type: "MemberExpression", object: declaration.init, property: property.key, computed: property.computed }
|
|
54
|
-
}
|
|
55
|
-
return declaration.init
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** The imported Grammar component's name; aliases and namespace imports keep their identity. */
|
|
59
|
-
export const grammarName = (context, opening) => {
|
|
60
|
-
const name = opening.name
|
|
61
|
-
if (name.type === "JSXIdentifier") {
|
|
62
|
-
const canonical = expressionName(context, name)
|
|
63
|
-
if (canonical) return canonical
|
|
64
|
-
return variableOf(context, name)?.defs.length ? null : name.name
|
|
65
|
-
}
|
|
66
|
-
if (name.type !== "JSXMemberExpression" || name.object.type !== "JSXIdentifier") return null
|
|
67
|
-
return expressionName(context, { type: "MemberExpression", object: name.object, property: name.property, computed: false })
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const expressionName = (context, expression) => {
|
|
71
|
-
const resolved = resolveLocal(context, expression)
|
|
72
|
-
if (resolved?.type === "MemberExpression") {
|
|
73
|
-
return namespaceName(context, resolved.object, resolved.computed ? stringOf(resolved.property) : resolved.property.name)
|
|
74
|
-
}
|
|
75
|
-
if (!["Identifier", "JSXIdentifier"].includes(resolved?.type)) return null
|
|
76
|
-
const imported = importOf(context, resolved)
|
|
77
|
-
return imported && GRAMMAR_SOURCE.test(imported.source) ? imported.imported : null
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const namespaceName = (context, expression, property) => {
|
|
81
|
-
const object = resolveLocal(context, expression)
|
|
82
|
-
if (!["Identifier", "JSXIdentifier"].includes(object?.type)) return null
|
|
83
|
-
const imported = importOf(context, object)
|
|
84
|
-
return imported && GRAMMAR_SOURCE.test(imported.source) ? property : null
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const objectValue = (context, expression, name, seen = new Set()) => {
|
|
88
|
-
const node = resolveLocal(context, expression)
|
|
89
|
-
if (node?.type === "ConditionalExpression") return conditionalObjectValue(context, node, name, seen)
|
|
90
|
-
if (node?.type !== "ObjectExpression" || seen.has(node)) return { known: false, value: null }
|
|
91
|
-
seen.add(node)
|
|
92
|
-
let result = { known: true, value: null }
|
|
93
|
-
for (const property of node.properties) {
|
|
94
|
-
if (property.type === "SpreadElement") {
|
|
95
|
-
const spread = objectValue(context, property.argument, name, seen)
|
|
96
|
-
if (!spread.known || spread.value !== null) result = spread
|
|
97
|
-
} else if (!property.computed && (property.key.name ?? property.key.value) === name) {
|
|
98
|
-
result = { known: true, value: property.value }
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
return result
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const conditionalObjectValue = (context, node, name, seen) => {
|
|
105
|
-
if (seen.has(node)) return { known: false, value: null }
|
|
106
|
-
const visited = new Set(seen).add(node)
|
|
107
|
-
const values = [node.consequent, node.alternate].map((branch) => objectValue(context, branch, name, new Set(visited)))
|
|
108
|
-
return { known: values.every((value) => value.known && value.value === null), value: null }
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** The effective prop, respecting JSX spread order; unknown later spreads cannot prove a value. */
|
|
112
|
-
export const propOf = (context, opening, name) => {
|
|
113
|
-
let result = { known: true, value: null }
|
|
114
|
-
for (const entry of opening.attributes) {
|
|
115
|
-
if (entry.type === "JSXSpreadAttribute") {
|
|
116
|
-
const spread = objectValue(context, entry.argument, name)
|
|
117
|
-
if (!spread.known || spread.value !== null) result = spread
|
|
118
|
-
} else if (entry.name.name === name) result = { known: true, value: attributeValue(entry), present: true }
|
|
119
|
-
}
|
|
120
|
-
return result
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** A statically proven string prop, or null for missing/unknown values. */
|
|
124
|
-
export const textProp = (context, opening, name) => {
|
|
125
|
-
const prop = propOf(context, opening, name)
|
|
126
|
-
return prop.known ? stringOf(resolveLocal(context, prop.value)) : null
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** The source spelling is semantic evidence for an icon or action whose translated label is dynamic. */
|
|
130
|
-
export const semanticText = (context, opening) => {
|
|
131
|
-
const values = ["source", "icon", "label", "aria-label", "id", ...ACTION_PROPS].map((name) => {
|
|
132
|
-
const prop = propOf(context, opening, name)
|
|
133
|
-
return prop.value ? context.sourceCode.getText(prop.value) : ""
|
|
134
|
-
})
|
|
135
|
-
return values.join(" ")
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** The right-cluster role and the Grammar kind that must draw it. */
|
|
139
|
-
export const clusterRole = (context, opening, name) => {
|
|
140
|
-
if (name === "Switch") return "theme"
|
|
141
|
-
if (name === "AvatarButton") return "account"
|
|
142
|
-
const text = semanticText(context, opening)
|
|
143
|
-
if (/language|locale|translate/i.test(text)) return "language"
|
|
144
|
-
if (/notification|bell/i.test(text)) return "notifications"
|
|
145
|
-
if (/account|avatar|profile/i.test(text)) return "account"
|
|
146
|
-
if (/theme|darkmode|color.?mode/i.test(text)) return "theme"
|
|
147
|
-
return null
|
|
148
|
-
}
|
package/lib/rule-contract.mjs
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
/** The ordered ownership test that governs every Grammar-specific lint rule. */
|
|
2
|
-
export const RULE_OWNERSHIP = "knowledge/patterns/fe/index.yaml#FE-RULE-OWNERSHIP"
|
|
3
|
-
|
|
4
|
-
/** Attach the governing ownership rule and the rule's behavioral source without changing enforcement. */
|
|
5
|
-
export const defineFamilyRules = (rules, knowledge) => {
|
|
6
|
-
for (const rule of Object.values(rules)) {
|
|
7
|
-
const source = rule.meta.docs?.url ?? knowledge
|
|
8
|
-
rule.meta.docs = { ...rule.meta.docs, url: source, knowledge: source, ownership: RULE_OWNERSHIP }
|
|
9
|
-
}
|
|
10
|
-
return rules
|
|
11
|
-
}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
// Generated by packages/stylelint-canon/lib/write-vocabulary.mjs from packages/grammar/src. Do not edit: run `npm run vocabulary` in packages/stylelint-canon.
|
|
2
|
-
|
|
3
|
-
/** The status tones of the grammar: each has a solid pair and a soft pair (`--<tone>-soft`, `--<tone>-soft-foreground`). */
|
|
4
|
-
export const STATUS_TONES = [
|
|
5
|
-
"success",
|
|
6
|
-
"warning",
|
|
7
|
-
"danger",
|
|
8
|
-
"info",
|
|
9
|
-
]
|
package/lib/why.mjs
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
/** Failure-code guidance for the selected Grammar family contract. */
|
|
2
|
-
export const why = {
|
|
3
|
-
"app-shell-frame": {
|
|
4
|
-
code: "FE_APP_SHELL_FRAME",
|
|
5
|
-
en: "The app frame must use Grammar WorkspaceShell with its fixed viewport.",
|
|
6
|
-
fix: "Compose WorkspaceShell viewport=\"fixed\" and supply its header, navigation and primary slots.",
|
|
7
|
-
},
|
|
8
|
-
"navbar-cluster": {
|
|
9
|
-
code: "FE_NAVBAR_CLUSTER",
|
|
10
|
-
en: "Navbar action component kinds or order differ from the app-shell canon.",
|
|
11
|
-
fix: "Compose Language IconButton, theme Switch, notification IconButton and account AvatarButton in that order.",
|
|
12
|
-
},
|
|
13
|
-
"navbar-control-size": {
|
|
14
|
-
code: "FE_NAVBAR_CONTROL_SIZE",
|
|
15
|
-
en: "A navbar control does not use Grammar's shared control height.",
|
|
16
|
-
fix: "Set size=\"control\" on every visible bar control.",
|
|
17
|
-
},
|
|
18
|
-
"no-navbar-create": {
|
|
19
|
-
code: "FE_NAVBAR_CREATE",
|
|
20
|
-
en: "A create action is composed into the global navbar.",
|
|
21
|
-
fix: "Move the create action to the page that owns it.",
|
|
22
|
-
},
|
|
23
|
-
"no-raw-structural-element": {
|
|
24
|
-
code: "FE_NATIVE_FORM_CONTROL",
|
|
25
|
-
en: "Raw `<tag>` in `<file>`. Page structure and text are composed from grammar components, not hand-written HTML.",
|
|
26
|
-
fix: "Replace it with the matching grammar component (`Heading`, `Text`, `SurfaceCard`, ...); if grammar lacks it, add it to grammar or the ui package instead of drawing it in place.",
|
|
27
|
-
},
|
|
28
|
-
"no-shell-bottom-tabs": {
|
|
29
|
-
code: "FE_APP_SHELL_BOTTOM_TABS",
|
|
30
|
-
en: "Bottom tab navigation replaces the required mobile sidebar drawer.",
|
|
31
|
-
fix: "Use WorkspaceShell navigationDrawer and Sidebar presentation=\"drawer\".",
|
|
32
|
-
},
|
|
33
|
-
"status-text-uses-soft-foreground": {
|
|
34
|
-
code: "FE_STYLE_TOKEN_ONLY",
|
|
35
|
-
en: "`<file>` paints text or an icon with a solid status tone (`<what>`, e.g. `text-success`). A solid tone is a background colour; used as a text colour it reads below 4.5:1 on the page background.",
|
|
36
|
-
fix: "Switch to the soft pair: `text-<tone>-soft-foreground` (with `bg-<tone>-soft` when it sits on a light surface); a solid tone is used only with `bg-<tone>` and `text-<tone>-foreground`.",
|
|
37
|
-
},
|
|
38
|
-
}
|
package/menu-entries.mjs
DELETED
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The menu entries a product hands to Grammar's `DropdownMenu`.
|
|
3
|
-
*
|
|
4
|
-
* HeroUI colours a destructive menu item through its `danger` variant, which Grammar exposes as `tone: "danger"`. An entry the app
|
|
5
|
-
* forgets to tone reads like any other command: sign out and delete sit beside "Settings" in the same ink, and the one click that
|
|
6
|
-
* cannot be taken back gets no mark. The decision is the entry's meaning, so it is checked where the entry is written: an entry whose
|
|
7
|
-
* id or label says it signs out, deletes, removes, leaves or revokes carries `tone: "danger"`.
|
|
8
|
-
*
|
|
9
|
-
* The terms are this canon's own list, English and Vietnamese. They are compared as whole words after the text is lower-cased, split
|
|
10
|
-
* at `-`, `_` and camelCase, and stripped of diacritics (the Vietnamese for "sign out" with its tone marks is `dang xuat`), so `signOut`, `sign-out`,
|
|
11
|
-
* `Sign out` and the tone-marked Vietnamese spelling are one term.
|
|
12
|
-
*
|
|
13
|
-
* WHERE IT LOOKS. A menu entry is an object literal in the `entries` attribute of a `DropdownMenu`, or in a declaration typed as
|
|
14
|
-
* `DropdownMenuEntry`, `DropdownMenuItem` or `DropdownMenuSection` (an array of them too), including the `items` of a section and the
|
|
15
|
-
* `submenu` of an item. Anything it cannot see through (a spread, a call) it leaves alone: the rule refuses to guess.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { resolveLocal } from "./lib/app-shell.mjs"
|
|
19
|
-
import { stringOf } from "@starci/eslint-canon-fe/lib/ast.mjs"
|
|
20
|
-
import { isProductSource } from "@starci/eslint-canon-fe/lib/scope.mjs"
|
|
21
|
-
import { defineFamilyRules } from "./lib/rule-contract.mjs"
|
|
22
|
-
|
|
23
|
-
/** The English terms of a destructive entry. */
|
|
24
|
-
const DESTRUCTIVE_TERMS_EN = Object.freeze([
|
|
25
|
-
"sign out", "log out", "logout", "signout",
|
|
26
|
-
"delete", "remove", "erase", "destroy", "discard", "purge",
|
|
27
|
-
"leave workspace", "leave team", "leave group", "leave organization", "leave project", "leave channel", "leave course", "revoke", "deactivate", "disconnect", "unlink", "unsubscribe", "terminate",
|
|
28
|
-
"close account", "cancel subscription", "cancel plan",
|
|
29
|
-
])
|
|
30
|
-
|
|
31
|
-
/** The Vietnamese terms, written without diacritics (the text is compared after the diacritics are stripped). */
|
|
32
|
-
const DESTRUCTIVE_TERMS_VI = Object.freeze([
|
|
33
|
-
"dang xuat", "xoa", "xoa bo", "go bo", "loai bo", "roi khoi", "roi nhom", "thu hoi",
|
|
34
|
-
"vo hieu hoa", "ngat ket noi", "huy dang ky", "huy goi", "cham dut", "dong tai khoan",
|
|
35
|
-
])
|
|
36
|
-
|
|
37
|
-
/** A destructive term as a whole word or phrase of the normalized text. */
|
|
38
|
-
const DESTRUCTIVE_PATTERN = new RegExp(String.raw`(?:^|\s)(?:${[...DESTRUCTIVE_TERMS_EN, ...DESTRUCTIVE_TERMS_VI].map((term) => term.replaceAll(" ", String.raw`\s+`)).join("|")})(?=\s|$)`)
|
|
39
|
-
|
|
40
|
-
/** The text lower-cased, split at separators and camelCase, with diacritics stripped. */
|
|
41
|
-
export const normalizeText = (text) =>
|
|
42
|
-
text
|
|
43
|
-
.replaceAll(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
44
|
-
.replaceAll(/[-_./:]+/g, " ")
|
|
45
|
-
.toLowerCase()
|
|
46
|
-
.replaceAll("\u0111", "d")
|
|
47
|
-
.normalize("NFD")
|
|
48
|
-
.replaceAll(/\p{M}+/gu, "")
|
|
49
|
-
.replaceAll(/\s+/g, " ")
|
|
50
|
-
.trim()
|
|
51
|
-
|
|
52
|
-
/** True when the id or label text names a destructive command. */
|
|
53
|
-
export const isDestructiveText = (text) => DESTRUCTIVE_PATTERN.test(normalizeText(text))
|
|
54
|
-
|
|
55
|
-
const MENU_TYPES = new Set(["DropdownMenuEntry", "DropdownMenuItem", "DropdownMenuSection"])
|
|
56
|
-
|
|
57
|
-
/** The value node of the property called `name` in an object literal, or null. */
|
|
58
|
-
const propertyValue = (object, name) =>
|
|
59
|
-
object.properties.find((entry) => entry.type === "Property" && !entry.computed && (entry.key.name === name || entry.key.value === name))?.value ?? null
|
|
60
|
-
|
|
61
|
-
/** The element type of an array type (`T[]`, `readonly T[]`, `ReadonlyArray<T>`), else the type itself. */
|
|
62
|
-
const elementTypeOf = (type) => {
|
|
63
|
-
if (type.type === "TSTypeOperator") return elementTypeOf(type.typeAnnotation)
|
|
64
|
-
if (type.type === "TSArrayType") return type.elementType
|
|
65
|
-
const isArrayName = type.type === "TSTypeReference" && type.typeName.type === "Identifier" && (type.typeName.name === "ReadonlyArray" || type.typeName.name === "Array")
|
|
66
|
-
return isArrayName ? (type.typeArguments ?? type.typeParameters)?.params[0] ?? type : type
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** True when the type annotation names a menu entry type or an array of one. */
|
|
70
|
-
const isMenuType = (annotation) => {
|
|
71
|
-
const type = annotation?.typeAnnotation
|
|
72
|
-
if (!type) return false
|
|
73
|
-
const element = elementTypeOf(type)
|
|
74
|
-
return element.type === "TSTypeReference" && element.typeName.type === "Identifier" && MENU_TYPES.has(element.typeName.name)
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Every possible value must prove danger; an unresolved value cannot mark a destructive action. */
|
|
78
|
-
const provesDangerTone = (context, expression, seen = new Set()) => {
|
|
79
|
-
const node = resolveLocal(context, expression)
|
|
80
|
-
if (!node || seen.has(node)) return false
|
|
81
|
-
if (node.type !== "ConditionalExpression") return stringOf(node) === "danger"
|
|
82
|
-
const visited = new Set(seen).add(node)
|
|
83
|
-
return [node.consequent, node.alternate].every((branch) => provesDangerTone(context, branch, visited))
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** Report an entry (an object literal), then walk the `items` of a section and the `submenu` of an item. */
|
|
87
|
-
function visitEntries(context, node, report) {
|
|
88
|
-
if (!node) return
|
|
89
|
-
if (node.type === "ArrayExpression") {
|
|
90
|
-
for (const element of node.elements) visitEntries(context, element, report)
|
|
91
|
-
return
|
|
92
|
-
}
|
|
93
|
-
if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression") {
|
|
94
|
-
visitEntries(context, node.expression, report)
|
|
95
|
-
return
|
|
96
|
-
}
|
|
97
|
-
if (node.type !== "ObjectExpression") return
|
|
98
|
-
const kind = stringOf(propertyValue(node, "kind"))
|
|
99
|
-
if (kind === "section") {
|
|
100
|
-
visitEntries(context, propertyValue(node, "items"), report)
|
|
101
|
-
return
|
|
102
|
-
}
|
|
103
|
-
const text = [stringOf(propertyValue(node, "id")), stringOf(propertyValue(node, "label"))].filter((value) => value !== null)
|
|
104
|
-
const tone = propertyValue(node, "tone")
|
|
105
|
-
const toned = provesDangerTone(context, tone)
|
|
106
|
-
if (!toned && text.some(isDestructiveText)) report(node, text.find(isDestructiveText))
|
|
107
|
-
visitEntries(context, propertyValue(node, "submenu"), report)
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** A destructive menu entry carries `tone: "danger"`. */
|
|
111
|
-
export const destructiveMenuEntryNeedsDangerTone = {
|
|
112
|
-
meta: {
|
|
113
|
-
type: "problem",
|
|
114
|
-
docs: { description: "A menu entry whose id or label signs out, deletes, removes, leaves or revokes carries `tone: \"danger\"`." },
|
|
115
|
-
schema: [],
|
|
116
|
-
messages: {
|
|
117
|
-
tone:
|
|
118
|
-
"The menu entry `{{text}}` is destructive but has no `tone: \"danger\"`. HeroUI colours the label and glyph of a danger item so the one click that cannot be taken back is marked; without the tone it reads like \"Settings\". Add `tone: \"danger\"` to the entry.",
|
|
119
|
-
},
|
|
120
|
-
},
|
|
121
|
-
create(context) {
|
|
122
|
-
if (!isProductSource(context)) return {}
|
|
123
|
-
const report = (node, text) => context.report({ node, messageId: "tone", data: { text } })
|
|
124
|
-
return {
|
|
125
|
-
JSXAttribute(node) {
|
|
126
|
-
if (node.name.type !== "JSXIdentifier" || node.name.name !== "entries") return
|
|
127
|
-
const element = node.parent?.name
|
|
128
|
-
if (element?.type !== "JSXIdentifier" || element.name !== "DropdownMenu") return
|
|
129
|
-
visitEntries(context, node.value?.type === "JSXExpressionContainer" ? node.value.expression : null, report)
|
|
130
|
-
},
|
|
131
|
-
VariableDeclarator(node) {
|
|
132
|
-
if (isMenuType(node.id.typeAnnotation)) visitEntries(context, node.init, report)
|
|
133
|
-
},
|
|
134
|
-
}
|
|
135
|
-
},
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** The rules this law contributes to the plugin. */
|
|
139
|
-
export const rules = {
|
|
140
|
-
"destructive-menu-entry-needs-danger-tone": destructiveMenuEntryNeedsDangerTone,
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
defineFamilyRules(rules, "knowledge/hfs/rules.yaml#R93")
|
|
144
|
-
|
|
145
|
-
/** Every rule is an error. */
|
|
146
|
-
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-grammar/${name}`, "error"]))
|