@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.
Files changed (73) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +4 -1
  3. package/dist/app-shell.d.ts +15 -0
  4. package/dist/app-shell.js +224 -0
  5. package/dist/chip-label.d.ts +14 -0
  6. package/dist/chip-label.js +117 -0
  7. package/dist/drawing.d.ts +8 -0
  8. package/dist/drawing.js +59 -0
  9. package/dist/grammar-boundary.d.ts +12 -0
  10. package/dist/grammar-boundary.js +88 -0
  11. package/dist/icon.d.ts +27 -0
  12. package/dist/icon.js +293 -0
  13. package/dist/index.d.ts +45 -0
  14. package/dist/index.js +73 -0
  15. package/dist/lib/app-shell-cluster.d.ts +6 -0
  16. package/dist/lib/app-shell-cluster.js +75 -0
  17. package/dist/lib/app-shell-create.d.ts +5 -0
  18. package/dist/lib/app-shell-create.js +37 -0
  19. package/dist/lib/app-shell-owner-source.d.ts +11 -0
  20. package/dist/lib/app-shell-owner-source.js +159 -0
  21. package/dist/lib/app-shell-owner.d.ts +7 -0
  22. package/dist/lib/app-shell-owner.js +97 -0
  23. package/dist/lib/app-shell-scope.d.ts +13 -0
  24. package/dist/lib/app-shell-scope.js +93 -0
  25. package/dist/lib/app-shell-visible.d.ts +13 -0
  26. package/dist/lib/app-shell-visible.js +117 -0
  27. package/dist/lib/app-shell.d.ts +27 -0
  28. package/dist/lib/app-shell.js +185 -0
  29. package/dist/lib/rule-contract.d.ts +12 -0
  30. package/dist/lib/rule-contract.js +23 -0
  31. package/dist/lib/status-tones.generated.d.ts +2 -0
  32. package/dist/lib/status-tones.generated.js +8 -0
  33. package/dist/lib/types.d.ts +113 -0
  34. package/dist/lib/types.js +2 -0
  35. package/dist/lib/why.d.ts +8 -0
  36. package/dist/lib/why.js +42 -0
  37. package/dist/menu-entries.d.ts +27 -0
  38. package/dist/menu-entries.js +142 -0
  39. package/dist/status-colors.d.ts +13 -0
  40. package/dist/status-colors.js +143 -0
  41. package/dist/tokens.d.ts +11 -0
  42. package/dist/tokens.js +166 -0
  43. package/dist/typography.d.ts +13 -0
  44. package/dist/typography.js +72 -0
  45. package/dist/vendor-boundary.d.ts +5 -0
  46. package/dist/vendor-boundary.js +25 -0
  47. package/docs/chip-label.md +30 -0
  48. package/docs/grammar-boundary.md +1 -1
  49. package/docs/status-colors.md +2 -2
  50. package/package.json +18 -15
  51. package/runtime/knowledge/hfs/canon-pins.yaml +11 -10
  52. package/runtime/knowledge/hfs/rules.yaml +23 -0
  53. package/app-shell.mjs +0 -208
  54. package/drawing.mjs +0 -60
  55. package/grammar-boundary.mjs +0 -94
  56. package/icon.mjs +0 -313
  57. package/index.d.ts +0 -28
  58. package/index.mjs +0 -78
  59. package/lib/app-shell-cluster.mjs +0 -66
  60. package/lib/app-shell-create.mjs +0 -33
  61. package/lib/app-shell-owner-source.mjs +0 -124
  62. package/lib/app-shell-owner.mjs +0 -83
  63. package/lib/app-shell-scope.mjs +0 -79
  64. package/lib/app-shell-visible.mjs +0 -92
  65. package/lib/app-shell.mjs +0 -148
  66. package/lib/rule-contract.mjs +0 -11
  67. package/lib/status-tones.generated.mjs +0 -9
  68. package/lib/why.mjs +0 -38
  69. package/menu-entries.mjs +0 -146
  70. package/status-colors.mjs +0 -140
  71. package/tokens.mjs +0 -164
  72. package/typography.mjs +0 -81
  73. package/vendor-boundary.mjs +0 -29
@@ -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")
@@ -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
- }
@@ -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"]))